diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ca8215d03..c6ff8859e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -39,3 +39,35 @@ updates: rust-dependencies: patterns: - "*" + + # The `fp` CLI (PyPI: fp-cli) — the only Python in the repo. Its lockfile is + # `fp-cli/uv.lock`, so the ecosystem is `uv`, and the directory is the package + # root rather than `/`. Grouped for the same reason as the Rust tree: the whole + # dependency set is verified together by one pytest run, and separate PRs each + # rebuild the lockfile the others just changed. + - package-ecosystem: uv + directory: /fp-cli + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + python-dependencies: + patterns: + - "*" + + # The telemetry SDK (PyPI: failproofai-sdk). Its own lockfile and its own uv + # ecosystem entry — dependabot resolves per directory, so fp-cli's entry above + # does not see this tree at all. The only things in the lockfile are the test + # runner and its transitive deps: the package itself declares NO runtime + # dependencies, and `tests/test_zero_dependencies.py` fails if that changes. + - package-ecosystem: uv + directory: /sdk/python + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + python-dependencies: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2971a18ec..41502f2b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,14 @@ name: CI on: push: branches: [main] + # `main` AND the long-lived branches other PRs stack onto. A pull request is + # tested against the branch it will actually merge into, and a stacked PR + # targeting anything but `main` matched nothing here — so #730, thirty commits + # of SDK work, ran no unit tests, no build and no lint at all. The only signal + # it produced was the daemon cross-compile, and only because it touched + # `crates/`. A PR that cannot go red is not a reviewed PR. pull_request: - branches: [main] + branches: [main, feat/fp-cli] concurrency: group: ci-${{ github.ref }} @@ -257,6 +263,217 @@ jobs: if: steps.crates.outputs.present == 'true' run: cargo test --workspace + # The `fp` CLI (PyPI: fp-cli). The only Python in this repo, and the only job that + # tests it — none of the bun/cargo jobs above look at fp-cli/ at all. It is matrixed + # across the Python versions pyproject.toml's requires-python advertises, because + # claiming >=3.10 and testing only one of them is how a 3.10 user finds the break. + fp-cli: + runs-on: ubuntu-latest + # a uv sync plus pytest across two interpreters; the bound exists so a stalled + # package mirror cannot hold a release for six hours (#726). + timeout-minutes: 10 + defaults: + run: + working-directory: fp-cli + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.13"] + steps: + - uses: actions/checkout@v7.0.1 + + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: fp-cli/uv.lock + + - name: Install dependencies + run: uv sync --locked --extra dev --python ${{ matrix.python-version }} + + # FP_CLI_REQUIRE_CONTRACT makes test_fp_home_contract.py FAIL rather than skip + # when it cannot read src/hooks/fp-home.ts. That file is the register for a + # home this CLI writes a credential into, and the assertions against it are + # the only thing keeping the two sides from drifting — before they existed, a + # rename on either side left both suites green. A guard that can quietly + # degrade to a skip is the same guard the SDK's spool contract used to be. + - name: Test + env: + FP_CLI_REQUIRE_CONTRACT: "1" + run: uv run pytest tests/ -q + + - name: Build the distribution + run: uv build + + # A stale `include = [...]` in [tool.setuptools.packages.find] builds a + # SUCCESSFUL but EMPTY wheel — pip installs it and the console script then + # ImportErrors. Nothing else in this job would notice, so assert the payload. + - name: Verify the wheel actually contains the package + run: | + python3 - <<'PY' + import glob, sys, zipfile + wheel = glob.glob("dist/*.whl")[0] + names = zipfile.ZipFile(wheel).namelist() + modules = [n for n in names if n.startswith("fp_cli/") and n.endswith(".py")] + print(f"{wheel}: {len(modules)} modules") + if len(modules) < 20: + sys.exit(f"wheel looks empty — only {len(modules)} modules under fp_cli/") + PY + + # `fp` is the contract users type. Prove the entry point resolves from a clean + # install of the built artifact, not from the source tree. + - name: Smoke-test the installed console script + run: | + uv venv /tmp/fp-smoke + VIRTUAL_ENV=/tmp/fp-smoke uv pip install dist/*.whl + /tmp/fp-smoke/bin/fp --version + /tmp/fp-smoke/bin/fp help > /tmp/fp-help.txt + if grep -qi agenteye /tmp/fp-help.txt; then + echo '::error::retired product name present in the fp help output' + exit 1 + fi + + # The telemetry SDK (PyPI: failproofai-sdk, import failproofai_sdk). Second Python + # component, sibling of fp-cli above, and deliberately its own job: it shares no + # lockfile, no dependencies and no release cadence with the CLI. + # + # The matrix is wider than fp-cli's two versions on purpose. This package declares + # no dependencies at all, so it has no third-party floor quietly constraining which + # interpreters it is really exercised on — and it is installed into other people's + # agent processes, whose Python version we do not choose. Every version + # requires-python advertises is tested. + failproofai-sdk: + runs-on: ubuntu-latest + # the same, across five — every version requires-python advertises; the bound exists so a stalled + # package mirror cannot hold a release for six hours (#726). + timeout-minutes: 10 + defaults: + run: + working-directory: sdk/python + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v7.0.1 + + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: sdk/python/uv.lock + + - name: Install dependencies + run: uv sync --locked --extra dev --python ${{ matrix.python-version }} + + # FAILPROOFAI_SDK_REQUIRE_CONTRACT makes test_spool_contract.py FAIL rather than + # skip when it cannot find crates/fpai-collect or src/hooks/fp-home.ts. Those + # assertions are the only check that this SDK writes where the daemon reads, and + # a guard that can silently degrade to a skip is not a guard — its predecessor in + # the agenteye repo skipped in every CI run for exactly this reason. + - name: Test + env: + FAILPROOFAI_SDK_REQUIRE_CONTRACT: "1" + run: uv run pytest tests/ -q + + - name: Build the distribution + run: uv build + + # A stale `include = [...]` in [tool.setuptools.packages.find] builds a + # SUCCESSFUL but EMPTY wheel — pip installs it and the import then fails. + # py.typed goes the same way if package-data stops naming it, and nothing + # else in this job would notice either. Assert the payload. + - name: Verify the wheel actually contains the package + run: | + python3 - <<'PY' + import glob, sys, zipfile + wheel = glob.glob("dist/*.whl")[0] + names = zipfile.ZipFile(wheel).namelist() + modules = [n for n in names if n.startswith("failproofai_sdk/") and n.endswith(".py")] + print(f"{wheel}: {len(modules)} modules") + if len(modules) < 7: + sys.exit(f"wheel looks empty — only {len(modules)} modules under failproofai_sdk/") + if not any(n.endswith("py.typed") for n in names): + sys.exit("py.typed is missing from the wheel — the type hints do not count without it") + if not any(n.endswith("LICENSE") for n in names): + sys.exit("LICENSE is missing from the wheel") + PY + + # `--no-deps` is the assertion, not an optimisation. "Zero dependencies" is the + # reason this package is safe to drop into someone else's agent, so prove it + # against the built artifact rather than the source tree: install it with nothing + # else present, emit real events, and read them back off disk. + - name: Smoke-test the installed package with no dependencies + run: | + uv venv /tmp/sdk-smoke + VIRTUAL_ENV=/tmp/sdk-smoke uv pip install --no-deps dist/*.whl + AGENTEYE_HOME=/tmp/sdk-spool /tmp/sdk-smoke/bin/python -c " + import failproofai_sdk as s + print(s.__version__) + s.event.agent_start(session_id='smoke', agent_id='a', goal='ci') + s.event.tool_use(session_id='smoke', agent_id='a', tool_name='t', tool_call_id='c') + s.event.tool_result(session_id='smoke', agent_id='a', tool_name='t', tool_call_id='c', output='ok') + " + python3 - <<'PY' + import glob, json, sys + batches = glob.glob("/tmp/sdk-spool/events/*.jsonl") + if not batches: + sys.exit("the installed wheel wrote no event batch") + events = [json.loads(line) for p in batches for line in open(p) if line.strip()] + types = {e["type"] for e in events} + if types != {"agent_start", "tool_use", "tool_result"}: + sys.exit(f"unexpected events from the installed wheel: {sorted(types)}") + # Emitted by the artifact, so this also proves the wire format survived + # packaging rather than only surviving an in-tree import. + if not all("environment" in e for e in events): + sys.exit("an event reached disk without the `environment` field") + print(f"{len(events)} events written by the installed artifact") + PY + + # The adapters' ONLY automated evidence. It is a separate job, not a leg of the + # matrix above, because installing five agent frameworks costs minutes and gains + # nothing from being repeated across five interpreters — the adapters bind to + # framework APIs, not to interpreter version. + # + # WHY THIS EXISTS: tests/integrations/* already honour + # AGENTEYE_TESTS_REQUIRE_FRAMEWORKS, and their comments say "CI leg sets + # AGENTEYE_TESTS_REQUIRE_FRAMEWORKS=1". No such leg was ever added, and the + # frameworks live in extras that `--extra dev` does not pull, so all four + # modules skipped at import in every run: 168 test functions across 6,071 lines, + # green, never executed. An adapter could break against a new framework release + # and nothing here would say so. That is the same "a guard that can silently + # degrade to a skip is not a guard" this file already hardened + # test_spool_contract.py against — the integration suites were simply missed. + failproofai-sdk-integrations: + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: sdk/python + steps: + - uses: actions/checkout@v7.0.1 + + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: sdk/python/uv.lock + + # Every framework extra. `--locked` keeps this honest: the lockfile already + # resolves all five, so a drift here fails rather than silently re-resolving. + - name: Install dependencies (all framework extras) + run: >- + uv sync --locked --extra dev + --extra langchain --extra langgraph --extra crewai + --extra llamaindex --extra pydantic-ai + --python 3.12 + + # AGENTEYE_TESTS_REQUIRE_FRAMEWORKS turns each module's import skip into a + # hard failure. Without it a botched install above would read as "4 skipped" + # and the job would pass having tested nothing — which is exactly the state + # this job was added to end. + - name: Test the framework adapters + env: + AGENTEYE_TESTS_REQUIRE_FRAMEWORKS: "1" + run: uv run pytest tests/integrations -q + test: runs-on: ubuntu-latest # The retry above nominally allows 3 attempts x 10 minutes. Capping the job diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml index c66e0370e..aeb1c0db3 100644 --- a/.github/workflows/osv-scanner.yml +++ b/.github/workflows/osv-scanner.yml @@ -66,9 +66,19 @@ jobs: id: scan uses: google/osv-scanner-action/osv-scanner-action@f4cfcc01edc9c8b756a9b873b7a623ca674da51e # v2.3.8 with: + # `--config` is load-bearing, not tidiness. Without it the scanner + # resolves a config PER SCANNED FILE, relative to that file — so the + # root `osv-scanner.toml` governed `bun.lock` and `Cargo.lock` and + # reached neither `uv.lock`. An ignore written for a finding in + # `sdk/python/uv.lock` loaded, filtered nothing, and was reported as + # an "unused ignore" while the gate stayed red. Naming it once makes + # one allow-list authoritative for every lockfile below. scan-args: |- + --config=osv-scanner.toml --lockfile=bun.lock --lockfile=Cargo.lock + --lockfile=fp-cli/uv.lock + --lockfile=sdk/python/uv.lock # Only the schedule run notifies — nothing on main touched the lockfile, # so nobody is watching it the way a PR author watches their own checks # or a push failure shows up against the commit they just merged. Same diff --git a/.github/workflows/publish-failproofai-sdk.yml b/.github/workflows/publish-failproofai-sdk.yml new file mode 100644 index 000000000..1234fd54f --- /dev/null +++ b/.github/workflows/publish-failproofai-sdk.yml @@ -0,0 +1,167 @@ +name: Publish failproofai-sdk to PyPI + +# The telemetry SDK (PyPI project: failproofai-sdk) ships on its own cadence, separate +# from the npm package, the daemon binaries in publish.yml, and its sibling fp-cli. It +# is a pure-Python wheel with no platform matrix and no release assets, so it has +# nothing to share with those pipelines beyond credentials it does not use. +# +# AUTHENTICATION IS PyPI TRUSTED PUBLISHING (OIDC) — there is no PYPI_API_TOKEN secret. +# This requires a one-time, out-of-band setup on PyPI that CANNOT be done from a PR: +# +# PyPI project `failproofai-sdk` -> Manage -> Publishing -> Add a new pending publisher +# Owner: FailproofAI +# Repository: failproofai +# Workflow name: publish-failproofai-sdk.yml +# Environment: pypi-failproofai-sdk <- REQUIRED, do not leave blank +# +# Until that publisher exists the `publish` job fails at the upload step with an OIDC +# error. Configure it BEFORE the first release, not after. +# +# The environment is load-bearing, not decoration. Every check in this file lives on the +# ref being dispatched, so a writer can delete them on a branch and click Run — the OIDC +# token is minted for whatever the workflow then asks for. Two settings OUTSIDE this file +# are what a branch edit cannot reach: +# +# 1. Repo -> Settings -> Environments -> `pypi-failproofai-sdk`: +# Deployment branches: `main` only (and add required reviewers if you want a +# second pair of eyes on every release). +# A job referencing this environment from any other ref is refused before it starts. +# NOTE: GitHub creates a missing environment implicitly, WITHOUT protection rules — +# so this is not self-configuring; set it up by hand. +# 2. The `Environment: pypi-failproofai-sdk` field above. PyPI then rejects a token +# whose `environment` claim is missing or different, which is what stops someone +# from simply deleting the `environment:` line below. + +on: + workflow_dispatch: + inputs: + dry_run: + description: Build and verify, but do not upload to PyPI + type: boolean + default: false + +concurrency: + group: publish-failproofai-sdk + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + # Gates this job on the repo-level environment rules described in the header, and puts + # the environment name in the OIDC token PyPI checks. Both halves are outside this file. + environment: pypi-failproofai-sdk + defaults: + run: + working-directory: sdk/python + permissions: + # Naming ANY scope sets every unnamed one to `none` — it is not an addition to the + # defaults. `contents: read` is therefore not redundant: without it `actions/checkout` + # gets a token that cannot read this repository. + contents: read + id-token: write # required for Trusted Publishing + steps: + # Authentication is OIDC, so there is no token to withhold: repo write access IS + # publish access, and `workflow_dispatch` can target ANY ref. Without these two + # checks one "Run workflow" click on an unreviewed branch ships that branch to + # public PyPI as an official failproofai-sdk release, unreviewable and + # unrecallable (PyPI versions cannot be reused). + # + # These are the readable guard, not the enforcement: they live on the ref being + # dispatched, so a writer could delete them on a branch. The `environment:` above + # plus its two out-of-file settings (see the header) are what actually hold. + - name: Authorize actor and branch + env: + ACTOR: ${{ github.actor }} + REF: ${{ github.ref_name }} + run: | + if [ "$ACTOR" != "NiveditJain" ]; then + echo "::error::Unauthorized. Only NiveditJain can publish failproofai-sdk." + exit 1 + fi + if [ "$REF" != "main" ]; then + echo "::error::Refusing to publish from '$REF'. failproofai-sdk publishes from main only." + exit 1 + fi + + - uses: actions/checkout@v7.0.1 + + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: sdk/python/uv.lock + + - name: Install dependencies + run: uv sync --locked --extra dev + + # Never publish a version whose tests do not pass. This duplicates the ci job on + # purpose: a workflow_dispatch can target any ref, including one CI never ran. + # FAILPROOFAI_SDK_REQUIRE_CONTRACT turns a missing daemon source into a failure + # rather than a skip, so the spool contract cannot go unverified on a release. + - name: Test + env: + FAILPROOFAI_SDK_REQUIRE_CONTRACT: "1" + run: uv run pytest tests/ -q + + - name: Build + run: uv build + + - name: Verify the artifacts before uploading + run: | + python3 - <<'PY' + import glob, sys, zipfile + wheel = glob.glob("dist/*.whl")[0] + names = zipfile.ZipFile(wheel).namelist() + modules = [n for n in names if n.startswith("failproofai_sdk/") and n.endswith(".py")] + if len(modules) < 7: + sys.exit(f"refusing to publish an empty wheel ({len(modules)} modules)") + if not any(n.endswith("LICENSE") for n in names): + sys.exit("refusing to publish without a LICENSE in the wheel") + # py.typed is the only thing that makes the annotations count for a + # type checker, and it is absent from the wheel unless package-data + # names it — a build that silently drops it looks identical here + # otherwise. + if not any(n.endswith("py.typed") for n in names): + sys.exit("refusing to publish without py.typed in the wheel") + print(f"{wheel}: {len(modules)} modules, licence and py.typed present") + PY + + # The CI job proves the artifact installs with no dependencies and emits real + # events; the publish path must not prove less than CI does. A wheel whose zip + # looks right but which cannot import, or which quietly acquired a dependency, + # is exactly the failure this catches — and it is unrecallable once uploaded. + - name: Smoke-test the artifact exactly as a user would receive it + run: | + uv venv /tmp/sdk-publish-smoke + # --no-deps is the assertion, not an optimisation: this package must + # install and work with nothing else present. + VIRTUAL_ENV=/tmp/sdk-publish-smoke uv pip install --no-deps dist/*.whl + AGENTEYE_HOME=/tmp/sdk-publish-spool /tmp/sdk-publish-smoke/bin/python -c " + import failproofai_sdk as s + print(s.__version__) + s.event.agent_start(session_id='smoke', agent_id='a', goal='publish check') + s.event.tool_use(session_id='smoke', agent_id='a', tool_name='t', tool_call_id='c') + s.event.tool_result(session_id='smoke', agent_id='a', tool_name='t', tool_call_id='c', output='ok') + " + python3 - <<'PY' + import glob, json, sys + batches = glob.glob("/tmp/sdk-publish-spool/events/*.jsonl") + if not batches: + sys.exit("the installed wheel wrote no event batch") + events = [json.loads(l) for p in batches for l in open(p) if l.strip()] + types = {e["type"] for e in events} + if types != {"agent_start", "tool_use", "tool_result"}: + sys.exit(f"unexpected events from the installed wheel: {sorted(types)}") + print(f"{len(events)} events written by the installed artifact") + PY + + - name: Report the version being published + run: | + VERSION=$(uv run python -c 'from failproofai_sdk._version import __version__; print(__version__)') + echo "failproofai-sdk version: ${VERSION}" + echo "### Publishing \`failproofai-sdk\` ${VERSION}" >> "${GITHUB_STEP_SUMMARY}" + + - name: Publish to PyPI + if: ${{ !inputs.dry_run }} + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: sdk/python/dist/ diff --git a/.github/workflows/publish-fp-cli.yml b/.github/workflows/publish-fp-cli.yml new file mode 100644 index 000000000..dda33f806 --- /dev/null +++ b/.github/workflows/publish-fp-cli.yml @@ -0,0 +1,145 @@ +name: Publish fp-cli to PyPI + +# The `fp` CLI (PyPI project: fp-cli) ships on its own cadence, separate from the npm +# package and the daemon binaries in publish.yml. It is a pure-Python wheel with no +# platform matrix and no release assets, so it has nothing to share with that pipeline +# beyond the registry credentials it does not use. +# +# AUTHENTICATION IS PyPI TRUSTED PUBLISHING (OIDC) — there is no PYPI_API_TOKEN secret. +# This requires a one-time, out-of-band setup on PyPI that CANNOT be done from a PR: +# +# PyPI project `fp-cli` -> Manage -> Publishing -> Add a new pending publisher +# Owner: FailproofAI +# Repository: failproofai +# Workflow name: publish-fp-cli.yml +# Environment: pypi-fp-cli <- REQUIRED, do not leave blank +# +# Until that publisher exists the `publish` job fails at the upload step with an OIDC +# error. Configure it BEFORE the first release, not after. +# +# The environment is load-bearing, not decoration. Every check in this file lives on the +# ref being dispatched, so a writer can delete them on a branch and click Run — the OIDC +# token is minted for whatever the workflow then asks for. Two settings OUTSIDE this file +# are what a branch edit cannot reach: +# +# 1. Repo -> Settings -> Environments -> `pypi-fp-cli`: +# Deployment branches: `main` only (and add required reviewers if you want a +# second pair of eyes on every release). +# A job referencing this environment from any other ref is refused before it starts. +# NOTE: GitHub creates a missing environment implicitly, WITHOUT protection rules — +# so this is not self-configuring; set it up by hand. +# 2. The `Environment: pypi-fp-cli` field above. PyPI then rejects a token whose +# `environment` claim is missing or different, which is what stops someone from +# simply deleting the `environment:` line below. + +on: + workflow_dispatch: + inputs: + dry_run: + description: Build and verify, but do not upload to PyPI + type: boolean + default: false + +concurrency: + group: publish-fp-cli + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + # Gates this job on the repo-level environment rules described in the header, and puts + # the environment name in the OIDC token PyPI checks. Both halves are outside this file. + environment: pypi-fp-cli + defaults: + run: + working-directory: fp-cli + permissions: + # Naming ANY scope sets every unnamed one to `none` — it is not an addition to the + # defaults. `contents: read` is therefore not redundant: without it `actions/checkout` + # gets a token that cannot read this repository. + contents: read + id-token: write # required for Trusted Publishing + steps: + # Authentication is OIDC, so there is no token to withhold: repo write access IS + # publish access, and `workflow_dispatch` can target ANY ref. Without these two + # checks one "Run workflow" click on an unreviewed branch ships that branch to + # public PyPI as an official fp-cli release, unreviewable and unrecallable + # (PyPI versions cannot be reused). The workflow this replaced + # (release-cli.yml in FailproofAI/agenteye) carried both; they were dropped in + # the move and are restored here. + # + # These are the readable guard, not the enforcement: they live on the ref being + # dispatched, so a writer could delete them on a branch. The `environment:` above + # plus its two out-of-file settings (see the header) are what actually hold. + - name: Authorize actor and branch + env: + ACTOR: ${{ github.actor }} + REF: ${{ github.ref_name }} + run: | + if [ "$ACTOR" != "NiveditJain" ]; then + echo "::error::Unauthorized. Only NiveditJain can publish fp-cli." + exit 1 + fi + if [ "$REF" != "main" ]; then + echo "::error::Refusing to publish from '$REF'. fp-cli publishes from main only." + exit 1 + fi + + - uses: actions/checkout@v7.0.1 + + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: fp-cli/uv.lock + + - name: Install dependencies + run: uv sync --locked --extra dev + + # Never publish a version whose tests do not pass. This duplicates the ci job on + # purpose: a workflow_dispatch can target any ref, including one CI never ran. + - name: Test + run: uv run pytest tests/ -q + + - name: Build + run: uv build + + - name: Verify the artifacts before uploading + run: | + python3 - <<'PY' + import glob, sys, zipfile + wheel = glob.glob("dist/*.whl")[0] + names = zipfile.ZipFile(wheel).namelist() + modules = [n for n in names if n.startswith("fp_cli/") and n.endswith(".py")] + if len(modules) < 20: + sys.exit(f"refusing to publish an empty wheel ({len(modules)} modules)") + if not any(n.endswith("LICENSE") for n in names): + sys.exit("refusing to publish without a LICENSE in the wheel") + print(f"{wheel}: {len(modules)} modules, licence present") + PY + + # The CI job proves the artifact is installable and correctly branded; the publish + # path must not prove less than CI does. A wheel whose zip looks right but whose + # console script does not resolve is exactly the failure this catches, and it is + # unrecallable once uploaded. + - name: Smoke-test the artifact exactly as a user would receive it + run: | + uv venv /tmp/fp-publish-smoke + VIRTUAL_ENV=/tmp/fp-publish-smoke uv pip install dist/*.whl + /tmp/fp-publish-smoke/bin/fp --version + /tmp/fp-publish-smoke/bin/fp help > /tmp/fp-publish-help.txt + if grep -qi agenteye /tmp/fp-publish-help.txt; then + echo '::error::retired product name present in the fp help output — refusing to publish' + exit 1 + fi + + - name: Report the version being published + run: | + VERSION=$(uv run python -c 'from fp_cli._version import __version__; print(__version__)') + echo "fp-cli version: ${VERSION}" + echo "### Publishing \`fp-cli\` ${VERSION}" >> "${GITHUB_STEP_SUMMARY}" + + - name: Publish to PyPI + if: ${{ !inputs.dry_run }} + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: fp-cli/dist/ diff --git a/.github/workflows/sync-failproofai-sdk-skill.yml b/.github/workflows/sync-failproofai-sdk-skill.yml new file mode 100644 index 000000000..513237b8a --- /dev/null +++ b/.github/workflows/sync-failproofai-sdk-skill.yml @@ -0,0 +1,162 @@ +name: Sync failproofai-sdk skill + +# One-directional mirror — MUST be run from `main`: +# FailproofAI/failproofai sdk/python/skill/ ──▶ FailproofAI/skills skills/failproofai-sdk/ +# +# This replaces `sync-python-sdk-skill.yml` in the private FailproofAI/agenteye repo, +# which mirrored the same skill from `python-sdk/skill/` to `skills/agenteye-python-sdk/` +# before the SDK moved here. That workflow was deleted with the SDK. +# +# Sibling of sync-fp-cli-skill.yml. The two are deliberately independent: every shared +# identifier below (SRC, DEST_SUBDIR, BRANCH, LABEL, concurrency group) differs, because +# BRANCH is force-pushed on every run — reusing the other skill's branch would overwrite +# its open PR with this skill's contents. +# +# The skills repo is pull-only — never hand-edit skills/failproofai-sdk/ there; edit +# sdk/python/skill/ here and let this run. It force-pushes ONE stable branch and reuses +# ONE PR (no pileup, no merge conflicts: every run rebuilds off latest main). +# +# One-time setup on FailproofAI/skills (admin) — REQUIRED BEFORE THE FIRST RUN: +# • create the label this workflow applies. `gh pr create --label` fails hard on an +# unknown label, so the first dispatch WILL fail without this: +# gh label create skill-sync-failproofai-sdk --repo FailproofAI/skills \ +# --color 1D76DB --description "Automated failproofai-sdk skill mirror PRs from FailproofAI/failproofai" +# • add the skill's row to the skills repo README "Skills in this collection" table and +# the CONTRIBUTING.md sync map. The rsync below only ever touches +# skills/failproofai-sdk/, so a green run still leaves the skill invisible on that +# repo's front page. +# • eventually delete the orphaned skills/agenteye-python-sdk/ folder, which teaches +# `import agenteye` and is no longer synced by anything. +# +# ⚠ NOT YET, and for the same reason as its fp-cli sibling: check the live public +# docs first. `docs/agenteye/python-sdk-skill.mdx` tells readers to run +# `npx skills add FailproofAI/skills --skill agenteye-python-sdk`. Deleting the +# folder before that page is repointed turns a documented install command into a +# not-found error. Order: land the docs change, THEN delete the folder. +# One-time setup on THIS repo (admin): +# • Actions secret SKILLS_SYNC_PAT — shared with sync-fp-cli-skill.yml, so it already +# exists if that one is set up. Fine-grained PAT scoped to FailproofAI/skills, +# Contents: Read and write + Pull requests: Read and write. The agenteye repo has a +# secret of the same name; this repo needs its own. +# +# Note: the validate step runs the skills repo's validate-skills.py with no path argument, +# so it validates the WHOLE skills repo. An unrelated broken skill there fails this run; +# that's a destination-repo problem, not a problem with this skill. + +on: + push: + branches: + - main + paths: + - 'sdk/python/skill/**' + workflow_dispatch: + +concurrency: + group: sync-failproofai-sdk-skill + cancel-in-progress: false + +permissions: + contents: read # GITHUB_TOKEN only reads this repo; all writes use the PAT + +env: + SRC: sdk/python/skill # source of truth (this repo) + DEST_REPO: FailproofAI/skills # mirror target + DEST_SUBDIR: skills/failproofai-sdk # folder overwritten in the mirror + BRANCH: sync/failproofai-sdk # stable bot branch (force-pushed each run) + LABEL: skill-sync-failproofai-sdk + REVIEWERS: NiveditJain,SiddarthAA + WORKDIR: /tmp/skills + +jobs: + sync: + name: Mirror skill and open PR + runs-on: ubuntu-latest + steps: + - name: Enforce run-from-main + run: | + if [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "::error::This workflow must be run from main (got $GITHUB_REF)." + exit 1 + fi + + - name: Checkout (source of truth) + uses: actions/checkout@v7.0.1 + + # The PAT holds Contents write + Pull requests write on the mirror repo, and the + # NEXT step runs a script fetched FROM that repo, inside this workspace. So the + # token must not land where that script can read it: a credentialed clone URL is + # written into $WORKDIR/.git/config verbatim and stays there for the rest of the + # job. `-c http.extraheader` (before the subcommand, so it is not persisted into + # the new repo's config) keeps it in this process only, and it comes from `env:` + # rather than being interpolated into the script body. + - name: Clone FailproofAI/skills + env: + SKILLS_SYNC_PAT: ${{ secrets.SKILLS_SYNC_PAT }} + run: | + AUTH="$(printf 'x:%s' "$SKILLS_SYNC_PAT" | base64 -w0)" + git -c "http.extraheader=AUTHORIZATION: basic ${AUTH}" \ + clone --depth=1 "https://github.com/${DEST_REPO}.git" "$WORKDIR" + + - name: Mirror sdk/python/skill/ → skills/failproofai-sdk/ + run: | + mkdir -p "$WORKDIR/$DEST_SUBDIR" + rsync -a --delete --exclude '.git' "$SRC"/ "$WORKDIR/$DEST_SUBDIR"/ + + - name: Validate (skills repo's own gate) + run: python3 "$WORKDIR/scripts/validate-skills.py" + + - name: Commit, push branch, open/refresh PR + env: + GH_TOKEN: ${{ secrets.SKILLS_SYNC_PAT }} + SKILLS_SYNC_PAT: ${{ secrets.SKILLS_SYNC_PAT }} + run: | + cd "$WORKDIR" + git config user.name "failproofai-sdk-skill-sync" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # `git status --porcelain`, NOT `git diff`: on the first sync of a new skill the + # destination folder does not exist yet, so every mirrored file is UNTRACKED — + # and `git diff` only ever looks at tracked files. It would report clean here and + # skip the publish, passing the run green having shipped nothing. This matters + # immediately: skills/failproofai-sdk/ does not exist yet. + if [ -z "$(git status --porcelain -- "$DEST_SUBDIR")" ]; then + echo "Already in sync — nothing to publish." + exit 0 + fi + + SHORT_SHA="${GITHUB_SHA::7}" + git switch -c "$BRANCH" + git add "$DEST_SUBDIR" + git commit -m "Sync failproofai-sdk skill from failproofai@${SHORT_SHA}" \ + -m "Automated mirror of sdk/python/skill/. Source of truth: FailproofAI/failproofai — do not hand-edit." + # The clone left no credentials on disk (see the clone step), so the push + # carries the same in-process header. + AUTH="$(printf 'x:%s' "$SKILLS_SYNC_PAT" | base64 -w0)" + git -c "http.extraheader=AUTHORIZATION: basic ${AUTH}" \ + push --force origin "$BRANCH" + + BODY="Automated mirror — **do not hand-edit this folder**. + + | | | + |---|---| + | Source of truth | [\`FailproofAI/failproofai\`](https://github.com/${GITHUB_REPOSITORY}) → \`${SRC}/\` | + | Target | \`${DEST_SUBDIR}/\` | + | Triggered from | \`main\` @ [\`${SHORT_SHA}\`](https://github.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}) | + + This replaces \`skills/agenteye-python-sdk/\`, which mirrored the same skill from the private agenteye repo before the SDK moved here and was renamed to \`failproofai-sdk\`. **That folder is now orphaned and should be deleted** — nothing syncs it, and it teaches \`import agenteye\`, which now installs a stranded CLI build instead. + + The skills repo's \`validate-skills.py\` passed. To change the skill, edit \`${SRC}/\` and re-run the **Sync failproofai-sdk skill** workflow — this same PR refreshes." + + if gh pr view "$BRANCH" --repo "$DEST_REPO" --json state --jq .state 2>/dev/null | grep -q OPEN; then + echo "PR already open for $BRANCH — branch force-pushed, PR refreshed." + else + gh pr create --repo "$DEST_REPO" --base main --head "$BRANCH" \ + --title "Sync failproofai-sdk skill (failproofai@${SHORT_SHA})" \ + --body "$BODY" \ + --label "$LABEL" + fi + + # Reviewers are tolerant: the PR author (PAT owner) can't review their own + # PR, so a failure here must not fail the sync. + gh pr edit "$BRANCH" --repo "$DEST_REPO" --add-reviewer "$REVIEWERS" \ + || echo "::warning::could not request reviewers $REVIEWERS (self-review or missing access?)" diff --git a/.github/workflows/sync-fp-cli-skill.yml b/.github/workflows/sync-fp-cli-skill.yml new file mode 100644 index 000000000..23df550ad --- /dev/null +++ b/.github/workflows/sync-fp-cli-skill.yml @@ -0,0 +1,148 @@ +name: Sync fp-cli skill + +# One-directional mirror — MUST be run from `main`: +# FailproofAI/failproofai fp-cli/skill/ ──▶ FailproofAI/skills skills/fp-cli/ +# +# This replaces `sync-skill.yml` in the private FailproofAI/agenteye repo, which +# mirrored the same skill from `cli/skill/` to `skills/agenteye-cli/` before the CLI +# moved here. That workflow was deleted with the CLI. +# +# The skills repo is pull-only — never hand-edit skills/fp-cli/ there; edit +# fp-cli/skill/ here and let this run. It force-pushes ONE stable branch and reuses +# ONE PR (no pileup, no merge conflicts: every run rebuilds off latest main). +# +# One-time setup on FailproofAI/skills (admin): +# • create the label this workflow applies: +# gh label create skill-sync-fp-cli --repo FailproofAI/skills \ +# --color 8A63D2 --description "Automated fp-cli skill mirror PRs from FailproofAI/failproofai" +# • eventually delete the orphaned skills/agenteye-cli/ folder, which teaches the +# retired `agenteye` command and is no longer synced by anything. +# +# ⚠ NOT YET. `docs/agenteye/cli-skill.mdx:68` still tells readers to run +# `npx skills add FailproofAI/skills --skill agenteye-cli`, and that page is live +# on the public docs site. Deleting the folder first turns a documented install +# command into a not-found error. Order: land the docs rewrite that repoints it at +# `fp-cli` (it rides with the /cloud/* docs move), THEN delete the folder. +# One-time setup on THIS repo (admin): +# • add Actions secret SKILLS_SYNC_PAT — a fine-grained PAT scoped to +# FailproofAI/skills, Contents: Read and write + Pull requests: Read and write. +# The agenteye repo has a secret of the same name; this repo needs its own. +# Until it exists this workflow fails at the clone step. + +on: + push: + branches: + - main + paths: + - 'fp-cli/skill/**' + workflow_dispatch: + +concurrency: + group: sync-fp-cli-skill + cancel-in-progress: false + +permissions: + contents: read # GITHUB_TOKEN only reads this repo; all writes use the PAT + +env: + SRC: fp-cli/skill # source of truth (this repo) + DEST_REPO: FailproofAI/skills # mirror target + DEST_SUBDIR: skills/fp-cli # folder overwritten in the mirror + BRANCH: sync/fp-cli # stable bot branch (force-pushed each run) + LABEL: skill-sync-fp-cli + REVIEWERS: NiveditJain,SiddarthAA + WORKDIR: /tmp/skills + +jobs: + sync: + name: Mirror skill and open PR + runs-on: ubuntu-latest + steps: + - name: Enforce run-from-main + run: | + if [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "::error::This workflow must be run from main (got $GITHUB_REF)." + exit 1 + fi + + - name: Checkout (source of truth) + uses: actions/checkout@v7.0.1 + + # The PAT holds Contents write + Pull requests write on the mirror repo, and the + # NEXT step runs a script fetched FROM that repo, inside this workspace. So the + # token must not land where that script can read it: a credentialed clone URL is + # written into $WORKDIR/.git/config verbatim and stays there for the rest of the + # job. `-c http.extraheader` (before the subcommand, so it is not persisted into + # the new repo's config) keeps it in this process only, and it comes from `env:` + # rather than being interpolated into the script body. + - name: Clone FailproofAI/skills + env: + SKILLS_SYNC_PAT: ${{ secrets.SKILLS_SYNC_PAT }} + run: | + AUTH="$(printf 'x:%s' "$SKILLS_SYNC_PAT" | base64 -w0)" + git -c "http.extraheader=AUTHORIZATION: basic ${AUTH}" \ + clone --depth=1 "https://github.com/${DEST_REPO}.git" "$WORKDIR" + + - name: Mirror fp-cli/skill/ → skills/fp-cli/ + run: | + mkdir -p "$WORKDIR/$DEST_SUBDIR" + rsync -a --delete --exclude '.git' "$SRC"/ "$WORKDIR/$DEST_SUBDIR"/ + + - name: Validate (skills repo's own gate) + run: python3 "$WORKDIR/scripts/validate-skills.py" + + - name: Commit, push branch, open/refresh PR + env: + GH_TOKEN: ${{ secrets.SKILLS_SYNC_PAT }} + SKILLS_SYNC_PAT: ${{ secrets.SKILLS_SYNC_PAT }} + run: | + cd "$WORKDIR" + git config user.name "fp-cli-skill-sync" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # `git status --porcelain`, NOT `git diff`: on the first sync of a new skill the + # destination folder does not exist yet, so every mirrored file is UNTRACKED — + # and `git diff` only ever looks at tracked files. It would report clean here and + # skip the publish, passing the run green having shipped nothing. This matters + # immediately: skills/fp-cli/ does not exist yet. + if [ -z "$(git status --porcelain -- "$DEST_SUBDIR")" ]; then + echo "Already in sync — nothing to publish." + exit 0 + fi + + SHORT_SHA="${GITHUB_SHA::7}" + git switch -c "$BRANCH" + git add "$DEST_SUBDIR" + git commit -m "Sync fp-cli skill from failproofai@${SHORT_SHA}" \ + -m "Automated mirror of fp-cli/skill/. Source of truth: FailproofAI/failproofai — do not hand-edit." + # The clone left no credentials on disk (see the clone step), so the push + # carries the same in-process header. + AUTH="$(printf 'x:%s' "$SKILLS_SYNC_PAT" | base64 -w0)" + git -c "http.extraheader=AUTHORIZATION: basic ${AUTH}" \ + push --force origin "$BRANCH" + + BODY="Automated mirror — **do not hand-edit this folder**. + + | | | + |---|---| + | Source of truth | [\`FailproofAI/failproofai\`](https://github.com/${GITHUB_REPOSITORY}) → \`${SRC}/\` | + | Target | \`${DEST_SUBDIR}/\` | + | Triggered from | \`main\` @ [\`${SHORT_SHA}\`](https://github.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}) | + + This replaces \`skills/agenteye-cli/\`, which mirrored the same skill from the private agenteye repo before the CLI moved here and was renamed to \`fp\`. **That folder is now orphaned and should be deleted** — nothing syncs it, and it teaches the retired \`agenteye\` command. + + The skills repo's \`validate-skills.py\` passed. To change the skill, edit \`${SRC}/\` and re-run the **Sync fp-cli skill** workflow — this same PR refreshes." + + if gh pr view "$BRANCH" --repo "$DEST_REPO" --json state --jq .state 2>/dev/null | grep -q OPEN; then + echo "PR already open for $BRANCH — branch force-pushed, PR refreshed." + else + gh pr create --repo "$DEST_REPO" --base main --head "$BRANCH" \ + --title "Sync fp-cli skill (failproofai@${SHORT_SHA})" \ + --body "$BODY" \ + --label "$LABEL" + fi + + # Reviewers are tolerant: the PR author (PAT owner) can't review their own + # PR, so a failure here must not fail the sync. + gh pr edit "$BRANCH" --repo "$DEST_REPO" --add-reviewer "$REVIEWERS" \ + || echo "::warning::could not request reviewers $REVIEWERS (self-review or missing access?)" diff --git a/.gitignore b/.gitignore index 7df567302..9d8a6cb95 100644 --- a/.gitignore +++ b/.gitignore @@ -136,5 +136,19 @@ COMMIT_MSG.tmp **/.failproofai/run/ **/.failproofai/state/ +# Python build and test artefacts from fp-cli/. `/dist` above is the Next.js one at +# the repo root; fp-cli builds into its own nested dist/, which that rule does not +# match, so it needs naming separately. +fp-cli/dist/ +fp-cli/.venv/ +fp-cli/.pytest_cache/ +.ruff_cache/ +sdk/python/dist/ +sdk/python/.venv/ +sdk/python/.pytest_cache/ +**/__pycache__/ +*.py[cod] +*.egg-info/ + # blog drafts (local, not for commit yet) /blog/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c205dc270..a23f0c0fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,34 @@ ### Docs +- **The SDK skill taught three things that had stopped being true, and its guard could not see prose.** `SKILL.md` described `tool_call_id` and `hook_id` as sharing one flat, process-wide correlation map — a real bug, fixed some time ago; the keys are `::` now, so they cannot collide, and the same package’s `events.md` already said so, leaving the skill contradicting itself. Both files claimed a float `duration_ms` was "dropped on the way in" and left the column empty; it raises `ValueError` at the call site. And the "Verify" section — the one an agent runs to decide whether an integration works — told the reader to `ls ~/.agenteye/events/`, which on a default install is empty, because the root moved to `~/.failproofai/custom-agents/events/`. A fresh integration would have been declared broken while every event landed correctly one directory over. Also fixed: the H1 and the frontmatter description still branded the product AgentEye, and the documented batch filename predated the pid/sequence stem. Added, because the SDK had them and the skill did not: `available()` / `active()` for answering "why is nothing recording", the `TypeError`/`ValueError` shapes for a non-string or blank identity, the comma rejection on `environment` (ingest splits on it, so a comma would discard the event), LlamaIndex’s `stale_after` / `reaper_interval`, and the framework extras — including that the extra is spelled `llamaindex` while the pinned distribution is `llama-index-core`. `test_skill_snippets.py` gains four checks on what the skill *says* rather than only whether it parses, and now dedents blocks the way `test_site_docs.py` always has, so a snippet nested in a list is testable instead of having to be hoisted out of the prose it belongs to. (#702) + +- **The integration guides read as walls of prose where the content was a comparison.** Six pages under `docs/start/integrations/` are restructured so the shape on the page matches the shape of the thing being explained. The redaction warning — the one a reader most needs to get right — became a three-row table of which events `collector.redact` touches and which it never sees, with the two levers that actually control payloads in a callout beside it. The `instrument()` ordering trap became a Wrong / Right / order-proof code group, because it is a bug you recognise by seeing it rather than by reading a paragraph about `sys.modules`. The id section became a numbered resolution order and a lookup table of what the cardinality guard does to each label shape. `custom-agents` gained the missing "what exists" view — all fifteen event methods in six families, openers against closers — before the code that calls them, and its three-seam mapping now leads the section it belongs to. LangChain’s node-naming and streaming notes were one paragraph holding two unrelated facts; they are now a table and two sections. CrewAI’s delegation nesting and LlamaIndex’s handoff turns are drawn as trees instead of described. Longest prose line across the set fell from 409 characters to under 300 on every page, and the shared heading spine `test_site_docs.py` enforces is unchanged. (#702) + +- **The SDK README documented the retired spool root in the three places a reader copies from.** The default moved to `~/.failproofai/custom-agents`, and `_resolver.py` says so in capitals, but the README's architecture diagram, its `configure()` comment and its JSONL example all still showed `~/.agenteye/events` — each contradicted by correct prose about the move two lines further down. A reader following the code rather than the paragraph tails a directory nothing writes to, sees an empty spool, and concludes the SDK is broken, which is the "an unread spool is indistinguishable from an idle one" failure the SDK exists to remove, relocated into its own documentation. The three sites are corrected, the `configure()` block additionally gains the `environment` argument its signature has always accepted and the README never listed, and `test_spool_contract.py` — which already pins this root across Python, Rust and TypeScript so the code cannot drift — now pins the README too, since it was the one copy of the contract nothing checked. (#702) + - Put the new `r/failproofai` subreddit everywhere the Discord invite already lives, so the second community channel is discoverable from the same places as the first: the README community badges (English + the 28 translated copies under `docs/i18n/` and `docs-old/i18n/`), the docs-site navbar (with its own hover tooltip in `custom.css`), the `failproofai --help` LINKS banner, the dashboard launch banner, the dashboard's "Reach Us" dropdown, and the comment the contributor-welcome workflow posts on every outside PR. (#732) +- Announce every stable release in Discord, from the notes on the GitHub Release. `publish.yml` gains an `announce` job that runs LAST — after the registry check and all four `verify-install` legs, because a channel told to install something that 404s is worse than a channel told nothing — and posts one embed to the webhook in `DISCORD_RELEASE_WEBHOOK`, pinging the role in `DISCORD_RELEASE_ROLE_ID`. **The GitHub Release body is the source of record and `CHANGELOG.md` the fallback**: stable releases are cut from the Releases page, and the notes written there are what the maintainer decided this release says — announcing from the changelog instead would publish a DIFFERENT summary than the one on the release page, in the channel where more people read it. The fallback covers an empty body and a `workflow_dispatch`, which has no release event at all; read from the changelog, a stable version also collects its whole `-beta.*` line, since somebody moving 1.0.0 → 1.0.1 on `latest` receives all of it and the stable section deliberately does not restate it. Entries collapse to their first sentence — the headline every entry in this file already opens with — and both `(#123)` and GitHub's `by @someone in ` become the same `#123` link. Three things are load-bearing and none are obvious: the mention goes in `content` because **Discord does not resolve mentions inside an embed** and would render `<@&id>` as raw text pinging nobody; `allowed_mentions: {parse: [], roles: [id]}` is what stops an `@everyone` in somebody's release notes reaching the whole server; and the description is fitted by dropping WHOLE groups rather than truncating, because the first version cut the trailing `[Full changelog]` link off a 1.0.0-sized release and ended on `Stop sending anything about a…` — a notification showing a third of a release and pointing nowhere. Stable only, and both halves of that are required: a prerelease version is a beta nobody asked to be pinged about, and a stable version at a dist-tag other than `latest` would carry an install line resolving to something else. Preflight refuses a stable release with notes in neither source, which is the one point in the pipeline where failing costs nothing. Nothing depends on the `announce` job, so a dead webhook can never hold back a published package. (#721) + ### Fixes +- **The docs told readers to call `model_response(response=...)`; the parameter is `content`.** `event.*` ends in `**fields`, so the wrong keyword was accepted, stored as an ordinary custom field, and the `content` column stayed empty — no exception, no warning, and a reader who copied the example got exactly the silent half-capture the SDK exists to prevent. It appeared three times on the docs site and in both of the SDK’s own shipped runnable examples, `quickstart.py` and `research_agent.py`. Also corrected: `instrument("crewai")` on a machine without CrewAI was documented as raising an `ImportError`; it does not. It logs a warning carrying that `ImportError` — whose message does name the exact install command — and returns `()`, so one missing framework cannot take down a process that instruments others. `FAILPROOFAI_SDK_STRICT=1` makes it raise, and that flag is read once and cached, so it has to be exported before the process starts. A new `test_every_documented_event_call_uses_real_keywords` checks the keywords of every documented `event.*` call against the real signature, across **every** page in the directory — the previous scans covered method names only, and only on the four framework pages, so `custom-agents.mdx`, which carries the most hand-written event calls on the site, was guarded by nothing at all. (#702) + +- **`fp users show/update/disable/enable` denied a member the CLI had just created.** The server lowercases an email on create, so `fp users create Alice.Chen@Example.com` stores `alice.chen@example.com` — and every later lookup on the exact string the caller had typed a moment earlier answered `no user with email "Alice.Chen@Example.com"` at exit 6. The member was reachable only through a lowercased form nothing had told them about, and exit 6 is the documented not-found code, so a script checking it concluded the user did not exist. `resolve_one` gains an opt-in `casefold`, used by the user resolver only: it is correct exactly where the server itself normalises, and key and query names are stored verbatim, so folding those would let `PROD` silently resolve `prod` and act on the wrong object. (#702) + +- **A batch the server stored none of was deleted, not parked — permanent, silent data loss.** `uploader.rs` reads the ingest ack precisely because, in its own words, "a batch the server discarded entirely is indistinguishable from a perfect upload", and `record_ack` states outright that "a 200 that stored nothing is an error, not a success". It then logged one ERROR line, returned `Ok`, and `upload_file` deleted the file — contradicting the module's other stated invariant, that `failed/` is "a retry queue, not a graveyard" holding "the last copy" of data the server does not have, "never deleted". A fully-skipped 200 was the one path that broke it, and the events were gone with no exception at the SDK call site, nothing in the dashboard, and nothing anywhere application code reads. Reproduced end to end: a single event carrying a ~12 MB tool output emitted 4 events and landed 3, permanently. `post_batch` now parks such a batch and returns the new `UploadError::StoredNothing`; parked **retryable** rather than poison-on-sight, because the observed trigger was an intermediary mangling an oversized body, which a retry can survive — `park_inner` bounds that, encoding the attempt in the filename and marking `.poison` at `failed_retries_max`, after which the file is kept forever and never retried again. Verified after the fix: the same 12 MB run leaves a 12,583,681-byte parked batch with all four lines intact and re-parseable. The pre-existing test that covered this asserted only the metrics — never that the file was deleted — so its intent is unchanged; it now also asserts the batch survives. (#702) + +- **`fp issues show ` exited 1 with an internal phrase where its sibling exited 6 with an answer.** `incidents_cmds._fail` remapped a non-UUID id to the friendly `no issue ` (exit 6) only when the status was `>= 500`, on the belief that the server answers a malformed id with a 500. It does not: axum's path extractor rejects it at **400** with a plain-text body, which the dashboard converts to the generic `upstream returned non-JSON response`. So the remap never fired, and the user saw that internal phrase at exit 1 while `fp audits show ` — the same code one file over — answered `no audit named "..."` at exit 6. Anything branching on exit 6 to mean not-found silently took the wrong arm. The guard is now `status == 400` and deliberately **not** `>= 400`: issue ids are not required to be UUIDs (`fp issues assign i1 --assignee ...` is a documented call), so a non-UUID id reaches real handlers and collects real 4xx answers — and `>= 400` rewrote a 422 "a@x.com is not an operator" into "no issue i1", replacing the one sentence explaining the failure with a false claim. Only 400 means the router refused to parse the id. Caught by two existing tests when the broader range was tried, and pinned by a new mirror of the audits regression test. (#702) + +- **Public-facing fixtures and help text pointed at `corp.com`, a real registered domain.** `fp users create dev@corp.com` is what `fp users --help` printed, so the address a reader is most likely to copy belonged to somebody else — 17 occurrences in shipped source and 54 in tests. RFC 2606 reserves `example.com` for exactly this and it is already in the tripwire's sanctioned-fixture vocabulary. Also: `.ruff_cache/` was ignored by nothing (it survived only on ruff's own self-ignore), and `fp-cli` advertised `requires-python = ">=3.10"` and tested 3.13 in CI while its classifiers stopped at 3.12, so PyPI would have under-reported what it supports. (#702) + +- **All four of the SDK's framework integration suites skipped in every CI run: 168 test functions across 6,071 lines, green, never executed.** They are the only automated evidence for native LangChain/LangGraph, CrewAI, LlamaIndex and Pydantic AI support, so an adapter could break against a new framework release with nothing to say so. The mechanism to prevent this already existed on the test side — each module honours `AGENTEYE_TESTS_REQUIRE_FRAMEWORKS`, and three of the four carry a comment reading "CI leg sets AGENTEYE_TESTS_REQUIRE_FRAMEWORKS=1" — but no such leg was ever added, and the frameworks live in per-adapter extras that `uv sync --extra dev` does not pull, so each module skipped at import instead. This is the same class as the `AGENTEYE_SPOOL_TO_FAILPROOFAI` opt-in the SDK's own resolver documents as having been "documented, tested, and unreachable", and the same one `FAILPROOFAI_SDK_REQUIRE_CONTRACT=1` was introduced to close for `test_spool_contract.py`. A `failproofai-sdk-integrations` job now installs all five extras with `--locked` and runs `tests/integrations` with the flag set, so a botched install fails rather than reading as "4 skipped". It is one job on one interpreter rather than a fifth leg of the existing matrix: the adapters bind to framework APIs, not to interpreter version, and installing five agent frameworks five times buys nothing. `__tests__/ci/failproofai-sdk-workflows.test.ts` pins all four invariants, the env var included, since dropping that line silently restores the exact state this closed. With the leg running, all 281 collected integration tests pass. (#702) + +- **`failproofaid --help` started the daemon instead of printing help.** `main()` matched `--version`/`-v` and let everything else fall through to `run()`, which takes the singleton lock and binds two sockets. So the one command an operator reaches for to discover the flags produced no output, never returned, and left a running daemon behind — a hang in a terminal, and an indefinite block in a script. Argument handling moves into a `parse_args` returning an `Invocation`, which is what makes the fall-through testable: `--help`/`-h` prints usage, an unrecognised `-`-prefixed argument is a usage error at exit 2 rather than a daemon start, and `--help` still wins over a later unknown option the way every other CLI behaves. Five unit tests cover it, including the regression itself. (#702) + +- **The tripwire that keeps a customer's name out of the public package named that customer, in cleartext, in its own docstring — and shipped it.** `fp-cli/tests/test_no_customer_identifiers.py` holds customer identifiers as SHA-256 digests precisely because, as its own header says, "a deny-list that spells out the name it exists to keep out of a public wheel publishes that name just as surely as the fixture did — and this file ships in the sdist." Line 16 then spelled it out anyway, in prose, three lines above the digest. It passed green for the same reason nobody noticed: `_scannable()` excludes this file from its own scan, an exemption that exists so the `FORBIDDEN_OWN` literals do not trip the scan on themselves, and which therefore blinds the scan to everything else in the file too. `tests/` is in the sdist, so publishing to PyPI would have published the name; the wheel excludes tests and was clean. The prose now describes the shape of the leak without naming it, and a new `test_this_file_does_not_name_the_customers_it_denies` runs the hashed scan over this file specifically — the one check the exemption cannot make — reporting `path:line` and the class of identifier, never the identifier, because that CI log is public. Negative-controlled by restoring the original line and confirming the new test is what fails. (#702) + - Stop the localized navigation referencing pages that were never translated, which is what still discarded a partial run. `--allow-partial` published what succeeded — and then `--update-nav` regenerated the nav from the ENGLISH tree, emitting an entry for the failed page in the language that failed it, so `mintlify validate` rejected the missing file and the job died before its push anyway. The 784 pages that HAD translated went with it, which is precisely the loss `--allow-partial` exists to prevent. Nav generation now omits any localized page whose file is not on disk, prunes a group left with no pages and a tab left with no groups, and keeps an `openapi` group that never had pages to begin with. The check is injected rather than hardcoded, so the pure transform stays testable and the two paths that actually write `docs.json` get the real one. This also closes the same hazard from every other direction it can arrive from — a pruned page, or a translation that only exists on an unmerged branch — because the nav is now derived from what is present rather than from what English says should be. (#725) - Stop the localized nav crashing on a group that has no pages, and stop it dropping the properties it does have. `buildLanguageNav` rebuilt every group as `{group, pages}` and called `group.pages.map(...)` unconditionally. The docs rebuild added `{group, expanded, openapi}` — a group whose content is an OpenAPI spec and has no pages at all — so `--update-nav` died with `TypeError: undefined is not an object`, **after 784 pages had already been translated**, taking the whole nightly run with it for the second night running. Groups are now rebuilt by spreading the English group, so `expanded`, `icon` and `openapi` survive instead of being silently discarded from every non-English nav; a group with no pages is carried through untouched (the spec is not translated, and dropping it would remove the API reference from thirteen languages); and `pages` entries that are themselves nested groups recurse rather than being prefixed as if they were paths. `pages` is optional on the type now, which is what it always was in the data. (#725) @@ -18,16 +42,106 @@ ### Features +- **`collector-health.json` reports delivery, not only capture.** The file answered "is each source producing events" and could not answer "is anything arriving". A source's job ends when it writes a batch into the spool — the POST, the server's verdict and the parking of what would not go all happen afterwards — and the SDK's batches have no source entry at all, because `failproofai-sdk` writes them into the spool from the user's own process. So a machine shipping nothing but SDK events wrote an empty, perfectly healthy-looking `sources` map whether ingest was storing every event or discarding all of them. Ingest answers `200` with `{"accepted":N,"skipped":M}` and the daemon deletes the batch either way, so one systematically malformed field discards everything the machine produced while every layer reports success — this release fixes two such fields — and the only trace was an ERROR line in the daemon's log, which on a real install is journald and nobody reads it until they already suspect a problem. The new `delivery` section carries counters the `Uploader` already kept: `accepted`, `skipped`, `batches_fully_skipped` and the timestamp of the last upload the server accepted. It is omitted rather than zeroed when there is no uploader, because all-zero counters and "this daemon has no credential" are different facts, and it reads through to the `Uploader` rather than copying at attach time, since that object outlives a supervised task restart and a snapshot would freeze the file at "nothing has happened yet" — which reads exactly like a healthy idle machine. (#730) + +- **The Python SDK gains native support for LangChain/LangGraph, CrewAI, LlamaIndex and Pydantic AI, on a real identity layer.** It was a capture surface you operated by hand: 15 emit methods, each requiring `session_id=` and `agent_id=`, and nothing propagating them — `SKILL.md` stated the absence of an ambient session as a deliberate contract, and `references/integration.md` shipped a ~60-line contextvars wrapper *as markdown for customers to paste into their own codebase*. `session()`, `agent()` and `tool_call()` bind identity on contextvars instead, under both `with` and `async with`, with `current()` and a `propagate()` that carries identity into a thread (contextvars do not). `session_id`/`agent_id` are now optional on all 15 methods, resolving from scope — existing call sites are untouched, which is why the golden wire-format bytes are unchanged. `instrument()` then wires the four frameworks, each a translation table over one shared `RunTracker` emitting only the existing 15 event types, so nothing fans out to the server, collector, CLI or stored schema. Measured against hand-written instrumentation on one identical task: 4 events and 4 types by hand, 14 events and 8 types from the adapter — the manual version reported one model request/response pair for a run that made two LLM calls, and zero tool events for a run whose point was calling a tool. That is the ceiling of the approach rather than carelessness: `graph.invoke()` is one call from outside, and the loop, the dispatch, the second round-trip and the per-node timings all happen inside it. AutoGen is deliberately absent — `autogen-core` last shipped 2025-09-30 and the live product is AG2, whose middleware has no global auto-instrument hook. Zero dependencies survives and its test got stronger: adapters resolve by string through `importlib` at call time, the source scan is scoped with a per-file allowlist, and a fresh interpreter now proves no framework reaches `sys.modules` on `import failproofai_sdk`. Verified live end to end for all four frameworks against a real model — every one of the 15 event types reaching the events store with promoted columns and `parent_id` nesting intact. Ported from FailproofAI/agenteye#503 and reconciled against the eleven SDK fixes made since that branch was cut. (#730) + +- **`request_id` on `model_request` and `model_response`.** The dashboard pairs model events on it, but no SDK method accepted one and no doc mentioned it — so every integration written to our own documentation emitted unpairable model events, `demo-agent/mock_agent.py` among them. Optional, and appended last in the ordered field list so an event omitting it serialises byte-for-byte as before: `test_wire_format.py` freezes those bytes, and ingest's dedup key hashes the canonical payload, so a reordering would stop retried batches collapsing and surface as duplicate rows rather than as an error. (#730) + - Give the canary box three images instead of one, and bake the commit into each. `failproofai-canary`, `failproofai-translate` and `failproofai-docs-audit` replace the single shared toolchain image, and each carries the checkout, its dependencies and its build products — the canary also carries a compiled `failproofaid`, which it used to cross-compile in a sibling rust container on every run. What happened at 02:00 and 11:00 in front of nobody — clone, fetch, checkout, `bun install`, two `bun build`s, a `cargo build` — happens in CI now, once per commit, where a failure is a red build rather than a night with no report. Installing the twelve vendor CLIs @latest deliberately stays at run time: that is the measurement, not setup. **Only the canary carries a docker client**, and only its cron line mounts the socket — the other two spawn nothing, and an image without the client cannot be talked into reaching the host daemon. That split is the reason for three images rather than one, and it is asserted rather than described. (#705) - Publish those images on **every** push to main, with no path filter — the exact inverse of the old rule, for the same reason it existed. Job scripts used to reach the box through a run-time clone, so only the baked layer needed rebuilding; with the tree baked in, any commit changes what the images should contain and a path filter would leave the box running last week's code with nothing saying so. The staleness that remains is answered rather than prevented: `FP_SHA` and `BUILT_AT` are baked in, the entrypoint turns them into an age, and a stale image says so at the top of its run **and in every Slack report it produces**. An unreadable build date counts as stale, because a job that cannot say how old its own code is should not be claiming freshness. The matrix is `fail-fast: false`, so one broken Dockerfile leaves the other two published rather than freezing all three. (#705) - Stop writing credentials into the checkout, which the images now bake. `ci-entrypoint.sh` decoded the vendor OAuth tarballs into `$REPO/tokens/` and assembled the gateway env-file at `$REPO/canary.env`; both move to a run-scoped directory. That was harmless while no image had a repo-root build context and stopped being harmless the moment one did — a cleanup trap that does not run is the normal case (a killed run, a `docker kill`), and `install.sh --build-local` builds from the operator's own working tree, which on the box is exactly where those files land. Three layers now hold the line: the paths move, a repo-root `.dockerignore` names them anyway (including the beta-leg variants `tokens-beta/` and `canary-beta.env`, which `.gitignore` never covered), and each Dockerfile **refuses to build** if one arrives — so a rename on the writing side fails a build instead of publishing a credential to a public registry. (#705) -- Announce every stable release in Discord, from the notes on the GitHub Release. `publish.yml` gains an `announce` job that runs LAST — after the registry check and all four `verify-install` legs, because a channel told to install something that 404s is worse than a channel told nothing — and posts one embed to the webhook in `DISCORD_RELEASE_WEBHOOK`, pinging the role in `DISCORD_RELEASE_ROLE_ID`. **The GitHub Release body is the source of record and `CHANGELOG.md` the fallback**: stable releases are cut from the Releases page, and the notes written there are what the maintainer decided this release says — announcing from the changelog instead would publish a DIFFERENT summary than the one on the release page, in the channel where more people read it. The fallback covers an empty body and a `workflow_dispatch`, which has no release event at all; read from the changelog, a stable version also collects its whole `-beta.*` line, since somebody moving 1.0.0 → 1.0.1 on `latest` receives all of it and the stable section deliberately does not restate it. Entries collapse to their first sentence — the headline every entry in this file already opens with — and both `(#123)` and GitHub's `by @someone in ` become the same `#123` link. Three things are load-bearing and none are obvious: the mention goes in `content` because **Discord does not resolve mentions inside an embed** and would render `<@&id>` as raw text pinging nobody; `allowed_mentions: {parse: [], roles: [id]}` is what stops an `@everyone` in somebody's release notes reaching the whole server; and the description is fitted by dropping WHOLE groups rather than truncating, because the first version cut the trailing `[Full changelog]` link off a 1.0.0-sized release and ended on `Stop sending anything about a…` — a notification showing a third of a release and pointing nowhere. Stable only, and both halves of that are required: a prerelease version is a beta nobody asked to be pinged about, and a stable version at a dist-tag other than `latest` would carry an install line resolving to something else. Preflight refuses a stable release with notes in neither source, which is the one point in the pipeline where failing costs nothing. Nothing depends on the `announce` job, so a dead webhook can never hold back a published package. (#721) +- **The SDK's default spool root is now `~/.failproofai/custom-agents`, not `~/.agenteye`.** It could always have been, through an `AGENTEYE_SPOOL_TO_FAILPROOFAI` opt-in — except that opt-in *also* required the directory to already exist, and nothing created it: not the SDK, not `failproofaid`, not either installer. `customAgentsEventsDir` in `fp-home.ts` was exported and called from nowhere, and the daemon computes the path only to watch it. So the branch never fired once, and every shipped SDK wrote to the legacy root regardless of what the operator set. The feature was documented, tested and unreachable. `failproofaid` has always watched **both** roots (`spool_dirs` in `crates/fpai-collect/src/config.rs`), so on a host running it this changes which directory the files land in and nothing else — and batches already spooled under `~/.agenteye/events` are not orphaned, they stay put and are still collected while that directory simply stops growing. The one host that breaks is one running the older `agenteye-collector`, which resolves `$AGENTEYE_HOME` or `~/.agenteye` and nothing else; it sets `AGENTEYE_HOME=~/.agenteye`, which is the documented escape hatch precisely because both daemons honour it and it therefore cannot itself desynchronise them. `AGENTEYE_SPOOL_TO_FAILPROOFAI` is retired rather than kept as a no-op — anyone who exported it was asking for this and now has it — and a test asserts no module reads it any more, checked over `os.environ` lookups rather than source text, because the frozen-strings guard was passing on a mention of the name in a comment while the variable itself was being deleted. (#702) + +- Open-source the telemetry SDK as `failproofai-sdk` (imported as `failproofai_sdk`), moved out of the private AgentEye monorepo into a new `sdk/python/`. It is the other end of the pipe from `fp-cli`: the agent calls this to record what it did, the CLI reads that back. `sdk/` is a directory rather than a flat `failproofai-sdk/` because more languages go beside `python/`, not inside it. The Python import name and the PyPI distribution name were the only things that changed at the point of the move — `AGENTEYE_HOME`, `AGENTEYE_ENVIRONMENT`, every event type and every payload key are a contract with `failproofaid` and the older `agenteye-collector`, and renaming any of them from the SDK's side would write events into a directory nothing watches, with no error on either side. (The default spool root did move later in this same release; see the entry above, which also explains why `AGENTEYE_HOME` is the escape hatch that keeps this contract intact.) `tests/test_server_contract.py` freezes those literals so a later rename sweep cannot take them. Zero runtime dependencies is now enforced rather than assumed: the source is checked for non-stdlib imports, the manifest for a `dependencies` key, and CI installs the built wheel with `--no-deps` and reads real events back off disk. Ships a matrixed CI job across all five Python versions `requires-python` advertises, a Trusted-Publishing PyPI workflow, a skill mirror, a `uv` dependabot ecosystem and the lockfile in the osv-scanner gate. (#702) + +- Open-source the Cloud CLI as `fp-cli` (command `fp`), moved out of the private AgentEye monorepo into `fp-cli/`. The distribution and the command differ because `fp` was taken on PyPI, and neither is the `failproofai` CLI this repo already builds — that one enforces inside the agent loop, this one reads back what the loop did. It is a hard cut, matching the collector binary's rename: no `agenteye` alias, no retired env-var fallback, no config migration, so scripts calling `agenteye ...` break on upgrade and users run `fp login` once. Env vars move to `FP_*` and the config to `~/.fp/cli.json`. The `X-AgentEye-Org` / `X-AgentEye-Client` headers and the `ae_session` cookie are deliberately unchanged — they are a contract with the dashboard and the Rust server, and renaming them from one side would misroute tenants with a 200 rather than an error. Brings the first Python into the repo: a matrixed `fp-cli` CI job, a Trusted-Publishing PyPI workflow, a `uv` dependabot ecosystem and the lockfile in the osv-scanner gate. Also fixes a README that documented a command renamed long ago and a default it claimed did not exist, a test fixture that ran the suite with TLS verification disabled, and a repo-root probe that would have resolved to the wrong repo here. (#702) ### Fixes +- **The Supply Chain gate blocked on a vulnerability with no fix and no path to us.** `sdk/python/uv.lock` locks every optional extra so CI can exercise the four framework adapters, which pulls `crewai` and, transitively, `chromadb` 1.1.1 — GHSA-f4j7-r4q5-qw2c, CVSS 9.3. OSV reports it as unfixable (`0 vulnerabilities can be fixed`, empty FIXED VERSION), so there is nothing to bump to, and it is not reachable from anything this package ships: the built wheel declares **no unconditional dependencies at all** — every `Requires-Dist` is gated behind an extra, and CI installs it with `--no-deps` — so `pip install failproofai-sdk` never brings it. It arrives only through `failproofai-sdk[crewai]`, which installs CrewAI, and anyone installing CrewAI has `chromadb` from CrewAI whether or not we exist. Ignored in `osv-scanner.toml` with a reason and a 2026-11-20 review date, the convention that file already documents — under **both** identifiers, because the scanner matches its `id` against the record's PRIMARY id, which for a PyPI advisory is the `PYSEC-` one. Two things had to be true for the entry to bite, and each failed silently on its own: the scanner resolves a config **per scanned file**, relative to that file, so the root `osv-scanner.toml` governed `bun.lock` and `Cargo.lock` and reached neither `uv.lock` — the ignore loaded, filtered nothing, and was reported as an "unused ignore" while the gate stayed red. `--config=osv-scanner.toml` is now passed explicitly, which makes one allow-list authoritative for every lockfile in the scan. The lockfile deliberately stays in the scan, so a *fixable* finding in it still blocks. (#702) + +- **`collector.hooks = false` silently stopped shipping the Python SDK's events.** `CollectorConfig::is_enabled()` gated the entire collector on `sessions || hooks`, and `collector_tasks()` returns early when it is false — so on a machine with a credential and both capture sources off, the daemon started no spool watcher and no sweeper, printed nothing at all, and every batch `failproofai-sdk` wrote into `custom-agents/events/` sat there forever. Those two settings gate the daemon's OWN sources (CLI session transcripts, hook activity) and each is checked again at the point its source is registered, so they were never what delivery should key off: the spool also carries events the user's own instrumented agents produced, and the watcher is the only thing that ships them. `hooks` defaults to true, which is why this stayed hidden — it takes turning hooks off, the one choice available to somebody who wants their instrumented agents shipped and nothing else, and then there is no error on either side and an unread spool looks exactly like an idle one. `is_enabled()` is now `ingest.is_some()`; an unconfigured machine still starts no thread and no runtime. Verified live: with `{"sessions":false,"hooks":false}` the daemon now logs `collector started tasks=3`, and a batch written before it came up was delivered by the sweeper. The reload end-to-end test that toggled `hooks` as a stand-in for disconnecting now toggles the **credential**, which is what `--disconnect` actually removes (`clearIngestCredential`) — a stronger test of the scenario it exists for — and a companion asserts the replacement behaviour: both capture sources off still starts the spool watcher, and still starts no hook source. (#730) + +- **`instrument()` called before the framework import said nothing and recorded nothing.** With no argument it instruments every framework already in `sys.modules`, so calling it above the `import langchain` line — the natural place to put a setup call, and where a "call this first" instruction lands people — finds nothing, installs nothing and returns `()`. The process then runs to completion with the SDK imported, the adapter "installed" and not one event emitted, raising nothing. The message naming the exact fix was already there, at `logger.debug`, which no default logging config displays: the single mistake that costs a user all of their telemetry was the one the SDK was silent about. It is a `logger.warning` now, and it fires only when somebody explicitly asked for instrumentation and got none. (#730) + +- **`SKILL.md` told readers `atexit` runs on `SIGTERM`. It does not.** The paragraph opened by calling `SIGTERM` "not exotic — it is every rolling deploy", every `docker stop`, every Kubernetes eviction, and then reassured them that "Python's default handler exits, so `atexit` *does* run". CPython installs no handler for `SIGTERM`: `signal.getsignal(SIGTERM)` is `SIG_DFL`, the OS terminates the process where it stands, and the atexit flush never runs. So the readers most likely to act on that paragraph — anyone deploying into a container — were told they were covered on the one exit path that drops the queue. Measured: a child queueing 20 events and sending itself `SIGTERM` writes **zero**. The text now says what happens, and ships the handler that fixes it (`flush_now()` then `sys.exit(128 + signum)`, which unwinds so an open `agent()` scope still emits its `agent_end`). Two tests execute both halves in a subprocess — the bare case must keep losing everything, so the day the SDK grows a handler of its own the recipe is flagged as obsolete rather than left standing. (#730) + +- **A comma in `environment` silently discarded every event the process emitted.** Ingest splits that field on commas to build its filter facets, so it skips any line containing one — the whole line, not the field — and answers `200 {"accepted":0,"skipped":N}`. The daemon deletes the delivered batch, and the run is simply not in the dashboard: no exception in the agent, nothing in its output, and an empty session list that looks exactly like an agent nobody ran. Measured against the running stack: `AGENTEYE_ENVIRONMENT="prod,eu"` — a wholly reasonable thing to type — produced `accepted:0, skipped:1`. `failproofaid` has always refused a comma in `collector.environment` for precisely this reason; the SDK writes the same field into every event and did not check. `configure(environment=...)` now raises naming the fix. The env var warns and falls back to `dev` rather than raising, because it is read lazily inside `to_dict()` on whatever event happens to be next: raising there would take the caller's agent down from a line of telemetry, and landing under a visibly wrong environment beats vanishing. (#730) + +- **A bare `llm.invoke()` recorded no model call at all.** A LangChain run with no parent is the session's root, and the adapter turned every root into an `agent_start`/`agent_end` pair — including a root whose own `run_type` is `chat_model`, which is what a direct `ChatOpenAI(...).invoke(...)` outside any graph produces. So that call emitted an agent span and **nothing else**: no `model_request`, no `model_response`, and therefore no model name, no input or output tokens and no latency, while the trace still looked populated and nothing raised. It is not an edge case — a classifier, a summariser and a one-shot rewrite are all shaped exactly like this, and a supervisor that delegates to graphs and then writes its own summary hits it on the summary. `_start_root` now also dispatches the leaf starter for a leaf-typed root and `_on_end` closes the leaf before the agent, so the pair lands inside its own span rather than after it — the dashboard closes the span at `agent_end` and anything later is attributed to nothing. Purely additive: a chain-typed root is untouched, and five tests cover it, four of which fail if the fix is reverted. (#730) + +- **Two LangChain runs that merely overlapped under one session id were read as an interrupt/resume, and one of them was silently deleted.** `_start_root` reused an open agent whenever the session already had one, because that is what a LangGraph resume looks like — but "the agent is still open" is equally true of two roots that simply run at the same time, and langchain-core opens one root run **per input** for `.batch()`. Any two requests carrying the same conversation id through the documented `failproofai_sdk_session_id` key do the same. The second root then got no `agent_start` at all, its work was relabelled with the first root's `agent_id`, the first root to finish closed the shared agent, and everything the other root emitted after that resolved to nothing and was dropped — a real model call, with its tokens and its latency, gone behind one `WARNING` nobody reads. Measured end to end against the running stack: two concurrent chains on one session landed **5 rows instead of 8**, and a `.batch()` of three landed 8 instead of 12, each with a single agent span. The discriminator is `open_pauses`: `_end_root` skips `agent_end` exactly when it is non-empty, which is the only way an agent outlives its root. Both directions are covered — the overlap case and a real interrupt/resume, which must still be one span. (#730) + +- **A failing top-level `tool.invoke()` or `llm.invoke()` counted as two failures.** `_on_end` returns straight after `_end_root` for a root run, so the line that marks a failure as owned by the span below it never ran for a root that was also a leaf. The exception was reported twice — once as `tool_result.error` / `model_response.error` and again as a standalone `error` event — and the server derives `is_error` from both, so `sessionSummary.errorCount` double-counted it while the identical failure one Runnable deeper counted once. Confirmed in ClickHouse: the same `ValueError`, raised at the top level and one level down, produced three error rows and two. The root-leaf branch now marks the failure as owned, exactly as the nested path does; a failure that no span below reported still gets its one standalone `error`, or it reaches no surface at all. (#730) + +- **`uninstrument()` went on recording forever when the trace env var was exported by somebody else.** A configure hook cannot be deregistered, so teardown is "make the hook produce nothing" — and neither lever did. Clearing the ContextVar only reaches contexts derived from the caller's, and the env var is unset only when `install()` was the one that set it, since it must not clobber an environment it did not write. With `FAILPROOFAI_SDK_TRACE_LANGCHAIN=1` exported by a Dockerfile or a CI job, `_configure` kept constructing a live zero-arg tracer per callback manager and a fully torn-down adapter recorded every event of every later run. Verified: a run issued **after** `uninstrument()` landed a complete four-event session in ClickHouse. There is now a module-level kill switch checked at the two entry points that gate everything else, so the promise holds however the hook was reached — and it is a switch, not a fuse: re-instrumenting works. (#730) + +- **`tool_result.output` was a Python repr of the envelope, and a tool that failed without raising was recorded as a success.** A tool handed the LLM's `ToolCall` dict — what `bind_tools` produces and what every modern tool loop passes — returns a `ToolMessage`, which has no JSON shape, so the single most-read field in a tool loop rendered as `ToolMessage(content='37000000', name='lookup_population', tool_call_id=…)` instead of `37000000`. The second half is worse: a `ToolMessage` carries `status="error"` when the framework converts the tool's exception into a message for the model instead of raising it, and `run.error` is empty on that path — so that failure had no representation anywhere, `is_error` 0 and a green span, with the text of it sitting in an output field nobody filters on. Both are read off the message now. (#730) + +- **Every LangChain entry on the Errors surface named its exception type twice.** `error` is the one event carrying `error_type` as its own field, and the server composes the row's `summary` as `": "`. The adapter fed it the same helper that fills `tool_result.error` and `agent_end.summary`, which prefixes the type because those two have nowhere else to say it — so every row read `ValueError: ValueError: denominator must be non-zero`. The CrewAI, LlamaIndex and Pydantic AI adapters all pass a bare `str(exc)` here; the fourth now agrees with them, and `agent_end.summary` deliberately still names the type, because nothing sits beside it. (#730) + +- **A stacked pull request ran no CI at all.** `ci.yml` triggers on `pull_request` into `main`, and #730 targets `feat/fp-cli` — so thirty commits of SDK work produced no unit run, no build, no lint and no docs check. The only signal it emitted was the daemon cross-compile, and only because it happened to touch `crates/`. A pull request that cannot go red is not a reviewed pull request. The trigger now also names `feat/fp-cli`, and turning it on immediately found what it was missing: the `fp-cli` and `failproofai-sdk` jobs carry no `timeout-minutes`, which main made mandatory in #726 and asserts in `release-pipeline.test.ts` — so #702 would have gone red on merge into main for a rule it had never been run against. Both jobs are bounded now. Also rewraps the daemon-skew warning in `fp-reset.ts`: "denies every tool call" is the consequence that message exists to state, and it was split across two hand-wrapped lines, so the test asserting the phrase failed against that branch of the message while the text read perfectly to a human. (#730) + +- **Five LangGraph bugs, one of them fabricating a human's approval.** `_node_of` decided which run was "the node" by comparing `run.name` to `metadata["langgraph_node"]` — and both sides of that are strings the user chooses. Three silent failures fell out of the one cause: `add_node("lookup_population", ToolNode([...]))` recorded **no `tool_use` or `tool_result` at all**, dropping the arguments, the result and the LLM's own `tool_call_id` and leaving two hook pairs where the tool should have been; `add_node("ChatOpenAI", ...)` recorded **no model call**, losing the model name, both token counts and the latency; and an inner runnable whose `run_name` matched the node key — or `sub.compile(name="child")` under `add_node("child", sub)` — emitted **two hook pairs per visit**, doubling node counts and halving apparent latency. Naming a node after the thing it runs is the obvious thing to type, and it was the thing that broke. A node's own run must now also be a non-leaf `run_type` carrying no `seq:step:` tag; both conditions are exclusions, so an upstream tag-convention change degrades to duplicate spans rather than to none. Worse than any of those: **a run that merely OVERLAPPED a pause was read as the resume.** `_start_root` treated "this session has an open pause and its agent is still open" as a continuation — a window that lasts as long as the human takes — so any other run carrying that session id inside it (a second request on one conversation id, a background summariser, a different graph) got no `agent_start`, had its nodes folded into the paused span, and emitted `agent_resume` + `human_input` **with an empty response**, closing the pause and reporting success. The dashboard showed an approval nobody gave. A resume must now also be shaped like one, which LangGraph makes checkable: an interrupted thread is continued only via `Command(...)` or `None`. And **a cross-process resume never closed the pause at all** — the real deployment shape, where one process interrupts and another approves: the second emitted nothing, so the session reported "still waiting on a human" forever and `pausedMs` never closed. The fix rests on three properties verified against the framework rather than assumed: `Interrupt.id` is `xxh3_128(checkpoint_ns)` and the interrupted task's namespace is byte-identical across the two invocations, so the second process reconstructs the id with no shared state; `on_resume` fires once per Pregel level, deepest last, which is what excludes a subgraph host; and only a level's first superstep re-runs interrupted tasks. Eighteen tests, including two that fail if the fix is pushed too far. (#730) + +- **Four Pydantic AI adapter bugs, two of them corrupting spans.** **A cancelled leaf closed after the agent it belongs to**, and sometimes not at all: `wrap_run` returns before `wrap_tool_execute` / `wrap_model_request` do on the cancellation path, because the graph awaits a gather of tool tasks and the run body unwinds the moment that future is cancelled, while each task's `CancelledError` lands a loop iteration later. Measured at `agent_end` .565709 against a matching `tool_result` at .566689 — and the dashboard closes the agent span at `agent_end`, so everything after it is attributed to nothing, which this adapter's own comments say three times. In some interleavings the ambient identity was already gone and the late leaf was dropped outright, leaving a `tool_use` with no result. Still-open leaves now close before `agent_end`, marked `fw_incomplete`. **`uninstrument()` during a live run emitted two `agent_end`s for one `agent_start`** — `cancelled` from teardown, then `success` from the run five seconds later, with the `tool_result` stranded between them. **`tool_result.output` was a Python repr of an envelope**: a tool returning `ToolReturn` recorded the whole repr, burying the answer beside `metadata` the model is documented never to see. And **a streamed `model_response.duration_ms` is the consumer's time** — on identical calls, 2556 ms with no consumer delay against 4059 ms with 1.5 s of sleep per delta, so 1503 ms of UI time sat inside the model's latency. No earlier hook is overridable without switching `agent.run()` into streaming mode, so the number cannot be made honest, only identifiable: `fw_streaming` now rides on the response as well as the request, since the request carries no `duration_ms` to exclude. (#730) + +- **A dataclass or a pydantic model recorded as a Python repr.** Neither is a `Mapping` or a `Sequence`, so both reached the branch that renders an object with no JSON shape — a tool's argument model, its structured return, a settings object on a request, all arriving as `Weather(city='Faro', celsius=21)`: a Python repr inside a JSON string, unreadable by `JSONExtract` and unfilterable on the dashboard. Every framework hands us these, and the adapters had begun solving it one at a time. The unwrap is deliberately shallow — `dataclasses.asdict` and `model_dump` both recurse and both copy, so on a large object they duplicate the whole tree before `_truncate` decides it only wanted the first 8 KB — and every part of it is guarded, because it runs the caller's own validators and properties; anything that raises falls back to `repr`, which is what happened before. `model_dump` rather than `dict`, because pydantic v2 names it distinctively while half the objects in a process have some attribute called `dict`, and a class is excluded explicitly, since `dataclasses.is_dataclass` is true of the class as well as its instances. (#730) + +- **Six CrewAI adapter bugs, four of them losing or misfiling events.** Found by driving real crews through a live gateway and reading the rows back out of the events store. **Hierarchical delegation was flattened**: `_tool_start` emitted `tool_use` but never noted the tool as a node, and CrewAI parents a delegated coworker's whole `AgentExecutionStartedEvent` on the `delegate_work_to_coworker` *tool* event — so the parent lookup missed it and fell back to the most recent root, putting the manager and both coworkers side by side under the crew instead of nesting coworker → manager → crew. **`FlowFailedEvent` was not in the translation table**, and a Flow whose method raises emits it and never emits `FlowFinishedEvent` — so the flow's `agent_start` was never closed and the session read `ongoing` forever, permanently in a long-lived process. **A Crew kicked off inside a Flow method became a second session**, because the flow-method span was not noted either, so `on_crew_started` read "no parent" and minted a new root: one logical run, two unlinked sessions, no `parent_id` on either. **Any event whose parent span was gone leaked into another run** through a process-global "most recent root" fallback — reproduced with two crews open, where an orphan tool emitted from crew Alpha's thread was recorded against crew Bravo; root selection now matches the ambient session. **`Task(human_input=True)` recorded nothing at all**: CrewAI has two HITL surfaces and only the Flow `@human_feedback` one is on the event bus, so the entire human wait was billed as active agent time — a real 38-second wait now measures as `agent_resume.duration_ms = 37878` inside a ~43-second agent span. That is the adapter's only patch, wrapping the narrowest seam, restoring the `staticmethod` descriptor on `uninstrument()` with an identity check and re-raising `KeyboardInterrupt` verbatim. And **`Agent.kickoff()` had no agent span at all** — the three LiteAgent events were unmapped, so with no ambient session it recorded zero rows and with one it filed everything under `agent_id="main"`. Thirteen tests, each fix reverted individually to prove the matching one fails. (#730) + +- **Three LlamaIndex adapter bugs, one flattening whole crews.** `AgentWorkflow` does not run its agents as nested workflows, so attribution from the span tree alone **collapsed a two-agent crew into a single `agent_id`** — 382 events under `"AgentWorkflow"` in the audited run, with the real names reachable only through `fw_agent_name`, a payload extra and not a groupable column. Each distinct `current_agent_name` now opens a nested agent under the workflow; the name is sticky because a `ToolCall` step carries none, and a guard stops a standalone `FunctionAgent` nesting inside itself. **A user-cancelled run was reported `outcome="success"`**: `cancel_run()` does not drop the span, the runtime catches its own `WorkflowCancelledByUser` and exits cleanly with `result=None`. Rather than infer cancellation from a null result, the adapter reads the framework's own `SpanCancelledEvent`, and the run closes `cancelled` with no `error`, because a stop button is not a failure. That event sits outside `_HANDLED_EVENTS` — the drift test walks only `llama_index.core.instrumentation.events.*` — so it ships with a drift guard of its own that fails if the class is renamed, moves, or loses `span_id`. And **a failed `agent_end` carried no `summary`**, the promoted column where a run's outcome is read; the reason lived only on the failing step's `hook_completed` payload and vanished entirely under `steps=False`. (#730) + +- **A tool's declared schema reached the events store as a Python repr.** `_core._truncate` and `_size` dispatched on the concrete `dict`, `list` and `tuple`, which misses every mapping a framework actually hands us that is not literally a dict — `MappingProxyType`, which is what `model_json_schema()` and any frozen config returns, and `ChainMap` among them — so those fell through to the branch that renders an object with no JSON shape via `repr`. A crewai `model_request` carried `tools[0].function.parameters.properties.from_unit = "{'title': 'From Unit', 'type': 'string'}"`: valid JSON holding a Python repr, so `JSONExtract` over it returns nothing and the field is unqueryable rather than merely ugly — and a tool's schema is exactly what you open a `model_request` to read. Both functions dispatch on `collections.abc.Mapping` and `Sequence`/`Set` now, with `str` and `bytes` handled first so a string cannot be exploded into a list of characters, and a counterweight test so widening the check cannot leave the repr branch dead. (#730) + +- **A per-run id inside an agent name poisoned the facet anyway.** `normalize_agent_id` exists because `agent_id` is a `LowCardinality(String)` and the primary facet on every dashboard surface, but it only caught a value that was an id *all the way through*. `agent-`, `crew_`, `task-3f9a1c2b-…` — a readable name carrying a per-run suffix — went straight past it, which is the shape frameworks actually produce and the exact one the CrewAI page already warns against. The id is stripped and the readable part kept, so `agent-` records as `agent` rather than collapsing to `main` and discarding the only meaningful token; dashed UUIDs are matched as a substring before the segment pass, or splitting on separators would break the most standard shape of all into five individually-innocent pieces. A name where nothing was stripped is returned unchanged, separators included, so this cannot quietly rename every `node_a_b` to `node a b`. (#730) + +- **CrewAI never recorded a crew blocked on a human.** `crewai.flow.runtime` emits `HumanFeedbackRequestedEvent` before it blocks and `HumanFeedbackReceivedEvent` after the answer; the adapter subscribed to neither, so the entire wait was an unexplained gap in the trace and the session's active duration absorbed it. The LangChain and LlamaIndex adapters both map their HITL surface onto the same four events and CrewAI now does too — `human_wait` + `agent_pause`, then `agent_resume` + `human_input`, in that order, because only the first pair carries the prompt and the answer and only the second feeds paused time. Two things surfaced while fixing it, each of which would have left the fix silently inert. The adapter resolved event classes against `crewai.events.event_types` alone, and the flow events are not in it — they are lazily re-exported from `crewai.events` — so the lookup returned `None`, the capability probe disabled that one hook, and nothing failed; `event_class()` now tries both namespaces, and the anti-drift test resolves through it rather than through a namespace of its own, since asserting against the narrower one is what let the gap exist. And CrewAI sets **no correlation id on either event** (`request_id` is `None` on both, `started_event_id` `None` on the received one), so pairing on it raised a `TypeError` inside the customer's event bus; the join is now `request_id` — which the enterprise async provider does set, and which can interleave — then `(flow_name, method_name)`, then the most recently opened pause, which is sound only because a console prompt blocks. Feedback for a request we never saw records the answer but deliberately withholds `agent_resume`, since closing a pause that never opened subtracts an interval that was never added. Six tests, all six failing if reverted. (#730) + +- **Corrected the LlamaIndex adapter's claim about token fidelity.** It said an integration naming its counters something unusual would show blank token columns, which reads as an exotic case. The common case is worse and was undocumented: `FunctionAgent` — the agent API LlamaIndex documents — calls `astream_chat`, and `llama-index-llms-openai` does not send `stream_options={"include_usage": True}`, so the provider never emits the usage chunk and `LLMChatEndEvent.response.raw` has no `usage` key on it. Verified by spying on the dispatcher directly against llama-index-core 0.14.23: every `LLMChatEndEvent` in a `FunctionAgent` run arrives with usage absent, so **every token count on the default agent path is null** and no instrumentation can recover a number the framework never received. Non-streaming `llm.chat`/`achat` extract usage correctly with no configuration. The one-argument user-side fix is now stated in the docstring, in the docs and used by the shipped examples: measured on the same run, `(None, None)` becomes `(148, 17)`. (#730) + +### Docs + +- Ignore `/blog/`, so long-form design write-ups can be drafted in the checkout without landing in a diff. These are contributor-local working files — a draft that is one `git add -A` away from being committed is a draft written more cautiously than it should be. Nothing shipped in the package changes. (#717) + +- **Audited every checkable doc claim against the SDK, and four were wrong.** The guides were written per-framework and the option lists were prose, so nothing caught a page describing an argument its adapter does not read — and nothing would have, because `instrument()` passes one dict to every adapter and drops unknown keys by design. A reader following those pages got no error and no effect. The CrewAI guide named `capture_content` (that adapter reads only `session_id`); the LlamaIndex guide named `session_id` and `capture_content` (it reads neither — its options are `embeddings`, `steps`, `capture_messages`, `stale_after` and `reaper_interval`, and its session comes from a scope); the Pydantic AI guide told readers to verify the install with `agent.capabilities`, which raises `AttributeError` because Pydantic AI merges the list into one `root_capability`; and the spool page printed a batch filename in a format the writer has never produced. LangGraph's options were correct. Also noted that `include_chains` applies to nested runs only — a top-level runnable is the session's root and becomes the agent span, so naming it there does nothing, which was verified rather than assumed. Three tests now hold the line: documented `instrument()` options are parsed out of each adapter's own source and compared, on both doc copies; the Pydantic AI verification snippet is pinned to `root_capability`; and the filename printed on the page is matched against one the writer actually wrote. Each was negative-controlled by reintroducing the original mistake. (#730) + +- **`sdk/python/docs/`: the guide and the code that proves it, in one tree.** Documentation and examples were two directories that had to be kept in agreement by hand, and the docs half was MDX — which renders as raw JSX tags anywhere except a Mintlify build, so on disk and on GitHub it read as broken markup. Both are now one Markdown tree, a directory per framework, each holding the guide somebody reads and the `examples/` they run: `docs//README.md` beside `docs//examples/*.py`. Five directories — `langgraph`, `crewai`, `llama_index`, `pydantic_ai` and `manual` for an agent with no framework, which also carries the three-seam recipe for any unsupported one and states why AutoGen has no adapter. Each guide covers install and supported version range, the three-line integration, the attach mechanism, a full framework-concept-to-event mapping table, a complete copy-pasteable program, agent naming, session resolution, every `instrument()` option, a real captured event payload, and pitfalls written as symptom then cause then fix. The pitfalls are the ones that actually cost time and were each hit while writing this: construct Pydantic AI agents *after* `instrument()` or they carry no capability and record nothing, with no error; `create_react_agent` aborts the graph on a raising tool unless the tool node sets `handle_tool_errors`; LlamaIndex needs one `stream_options` argument or every token count is null; and never read the spool to verify anything, because a running `failproofaid` deletes each batch within milliseconds and the read races it. (#730) + +- **Every example was executed against a live model before it shipped, and each prints its own trace.** Eleven scripts across the five directories — a supervisor delegating to two workers (38 events, 5 agents), a bare OpenAI tool-calling loop instrumented entirely by hand (14 events, no framework), and multi-tool runs for each adapter including one deliberately failing tool so the recovery path is visible. The traces quoted in the guides are captured output, not illustrations. Each ends by printing the event stream it produced, captured by tapping the writer in-process rather than reading the spool, for the reason above. `test_examples.py` and `test_docs.py` merge into one `test_docs.py` that walks the whole tree: a framework with an adapter and no directory fails, a guide linking to an example that does not exist fails, a guide or example naming SDK API that does not exist fails, and an example threading `session_id=` by hand fails — checked over the AST, so the manual guide can still *explain* the argument its whole purpose is to replace. (#730) + +- **Event pairings key on the session, never on the agent.** `_pending` correlates a start event with its end so `duration_ms` can be measured, and the key had been wrong twice in opposite directions. Bare ids let a tool call and a hook sharing an id consume each other's timestamp; adding the session fixed a real collision between two concurrent runs in one process. Adding the AGENT as well was over-tightening, and is reverted here: once a framework runs tools inside sub-agents — LangGraph and CrewAI both do — a `tool_use` opened under `planner` and closed under `worker` is the ordinary case, and an agent-scoped key makes those pairs miss entirely, silently dropping `duration_ms` for exactly the nested runs that most need it. The rule that survives both: include what makes the id unique (kind, session), exclude what can legitimately change between the two events. Applied to all four pair types. Correlation keys never leave the process, so no wire format changes — only `duration_ms`, in the colliding cases, from a fabricated or missing value to a correct one. (#730) + +- **Guard the `fp-cli` credential path against drifting from the layout register.** `~/.failproofai/` is a governed layout — `src/hooks/fp-home.ts` declares it and "nothing outside this file may join a path onto the failproofai home" — and what actually keeps a reset off the CLI's session is its `user-typed` entry in `HOME_CLASSES`, since `resettablePaths()` is a *filter over* that table. Nothing checked that the two sides agreed, and `config.py` said so itself above `FPCLI_SUBDIR`: "change one, change the other; nothing checks". Confirmed by experiment: renaming `fpcliDir` to `fp-cli` in the TypeScript and leaving Python alone left 53 TS tests and 59 Python tests all passing, with the register describing a directory nothing writes and the real credential at a path it had never heard of — safe only until somebody classifies its parent. `fp-cli/tests/test_fp_home_contract.py` now reads `fp-home.ts` and pins the subdirectory name, the filename, the home directory, the `FAILPROOFAI_HOME` override, the `user-typed` classification and the deliberate absence of a class on the directory itself. It mirrors the SDK's `test_spool_contract.py`, including the parts that stop a source-reading test passing vacuously: every pattern must match exactly once, the anchors are asserted separately, and CI sets `FP_CLI_REQUIRE_CONTRACT=1` so a moved register fails instead of skipping. Verified end to end as well — a real `fp` session planted in a populated home survives a real `resettablePaths()` reset, while `audit/cache` beside it is removed. (#702) + +- **Three comments about the CLI's home contradicted the shipped code.** `fp-home.ts` said the move from `~/.fp/cli.json` "deliberately did NOT migrate the old file… so the upgrade costs a login rather than a credential-rewriting code path that runs once and is never tested again"; `config.py` said the legacy file is "Never read, never written, never deleted"; and `test_failproofai_home.py`'s own module docstring said it is "neither read nor deleted", 200 lines above the tests that assert it IS adopted. All three describe the behaviour before `ce2012db` added adoption: `load_config` reads the pre-move session, writes it to the new path and leaves the original so a downgrade still finds it — best-effort, and it costs no login. The tests were updated when adoption landed and the prose was not. Also corrected `fp-home.ts`'s citation of `home-classification.test.ts`, a file that has never existed; the classification guard is real and lives in `__tests__/hooks/fp-home.test.ts`. (#702) + +- **The SDK's cross-component contract test aborted the whole suite outside the repo.** `test_spool_contract.py` resolved `REPO_ROOT` with `Path(__file__).resolve().parents[3]`, which raises `IndexError` on a shallower tree — and a shallower tree is exactly the packaged-sdist case the file's own `_read_sibling` is written to handle ("in a packaged sdist that is expected"). Because it raised at import, pytest reported a collection error and stopped: running the suite from a copy of `sdk/python` turned 428 passing tests into `1 error`, so the graceful path was unreachable in the one situation it exists for. `REPO_ROOT` is guarded now and the existing skip/fail logic decides, as designed — 423 passed with 8 skips outside the repo, 429 with 2 inside. (#702) + +- **`event.*()` could raise `KeyError` into the caller's own agent loop.** `_track_pending` did `len()`, then `next(iter())`, then `del`, with nothing serialising the three — so two threads arriving at a full `_pending` picked the same victim and the second `del` raised, straight out of `event.tool_use()`. Measured at 24 crashes per 30_000 calls across 10 threads. It fires only once the map is full, which is exactly the long-running multi-agent process the cap exists for, so "rare" here meant "only in production". Eviction is tolerant now — `pop(key, None)`, and `next(iter())` guarded for the `StopIteration`/`RuntimeError` shapes a concurrent mutation produces. Deliberately no lock: a lock held at the instant of a `fork()` is inherited locked by a thread that does not exist in the child, which is the hazard `_writer` rebuilds its Event and lock to avoid. (#702) + +- **An exploding `__repr__` re-opened the permanent spool wedge.** `_encode_entry` caught `(TypeError, ValueError, RecursionError)`, but `default=str` runs the CALLER'S `__repr__` — which can raise anything, a `RuntimeError` from a lazy ORM attribute or an `OSError` from a property that touches the network. Those escaped, propagated out of `_write_batch`, and put the whole batch back on the queue to be retried identically forever: the same wedge the isolation exists to prevent, reached through a different exception type. The fast path catches `Exception` now — never `BaseException`, so a Ctrl-C during a flush still interrupts. (#702) + +- **A non-string `session_id` or `agent_id` was dropped by the server at `200 OK`.** Verified live: `{"accepted":0,"skipped":1}` for an int, a null or an object. The SDK reported success, the collector deleted the batch, and the event was gone with no error anywhere — and `None` is the realistic way in, from an uninitialised variable or a lookup that missed. Both are validated at the boundary on all 15 event methods now. Blank ids are refused too, and those the server *accepts*: the worse outcome, since every event lands and is silently grouped under one empty id, so the data looks present while being quietly merged across unrelated runs. (#702) + +- **A stuck write stranded one `.tmp` per flush cycle.** Each flush picks a fresh stem, so a persistent fault — a full disk, a read-only mount, a cross-device rename — leaked a file every interval: roughly 170_000 a day at the default 500 ms, on the very disk already in trouble, and invisible because the watcher ignores them by extension. The partial file is removed on any failure now; the batch itself is untouched by that, since `_flush` returns the entries to the queue and the next cycle rewrites them. (#702) + +- **A lone surrogate made the server skip the entire event, silently.** `os.fsdecode` and `bytes.decode(errors="surrogateescape")` — how Python carries bytes that are not valid UTF-8, and what a filesystem path or a truncated tool output arrives as — produce them, and `json.dumps` escapes them to the literal text `\udcff` without complaint. So nothing failed locally and ingest answered `{"accepted":0,"skipped":1}` at 200. Strings are scrubbed with `backslashreplace`, which keeps the offending byte legible in the payload rather than dropping it to a `?`, and the encoder reaches that path via one substring scan per line so a clean event still takes the fast path untouched. Verified live: skipped before, accepted after. (#702) + +- **SDK batches are now durably committed, not merely atomically published.** The writer used `write_text()` then `os.replace()`. A rename is atomic with respect to READERS, but it commits nothing to the platter — so a power loss or kernel crash could leave a correctly-named, zero-length or truncated `.jsonl`. The collector reads whatever is there, POSTs it, takes the 200, and then DELETES the file (`remove_file` in `crates/fpai-collect/src/uploader.rs`), which makes the loss permanent and silent. This was not a subtle oversight so much as an asymmetry: this repo's own Rust spool writer has called `sync_all()` at exactly this point from the start, with a comment saying why, and the Python writer publishing into the same directories was the odd one out. The batch is now fsynced before the rename and the parent directory after it — the second half matters because the reverse failure leaves the bytes on disk under a `.tmp` name the watcher ignores by design. The directory fsync is best-effort, since opening a directory for fsync is a POSIX behaviour. (#702) + +- **A measured `duration_ms` is now held to the same range the SDK refuses a caller.** `_validate_promoted_numeric` rejects anything outside an unsigned 32-bit integer, because `pu32()` stores NULL for the rest at `200 OK` — but the SDK's OWN computation was unbounded, so the same field was held to two different standards depending on who produced it. Two ways out of range, neither of them abuse: over, because 2^32 ms is ~49.7 days and a `human_wait` answered after a long weekend or an `agent_pause` resumed a month later is an ordinary lifetime for those pairs; and under, because these are wall-clock readings and an NTP step backwards yields a negative interval that `round()` faithfully preserves into an unsigned column. The four inline computations are one helper now, and an out-of-range interval is omitted with a warning rather than clamped — a clamped 49.7 days is indistinguishable from a measurement, and the whole reason this is computed rather than accepted is that a reported duration is unfalsifiable. (#702) + +- **Non-finite floats no longer emit invalid JSON.** `json.dumps` writes `NaN`, `Infinity` and `-Infinity` by default; they are a Python extension, not JSON, and a strict NDJSON reader rejects the line. Worse than being wrong, it was silent: `json.dumps` does not raise on them, so the encoder's sanitising fallback was never reached and the malformed line went out looking like a clean success. Both encode paths pass `allow_nan=False` now, which turns a non-finite float into an ordinary encode failure, and `_sanitize` maps it to `null` — the field reads as absent rather than as a number that is not one. Verified end to end: a payload carrying `NaN` and `inf` arrives in ClickHouse as `{"budget": null, "confidence": null, "label": "kept"}`, with the sibling field intact. (#702) + +- **The documented `tool_call()` bracket no longer drops its closing event on cancellation.** It caught `Exception`, and `asyncio.CancelledError` inherits straight from `BaseException` — so a cancelled async tool emitted `tool_use` with no matching `tool_result`, leaving an orphaned event that also holds its correlation slot until the cap evicts it. Cancellation is the ordinary way an async tool ends when a timeout fires or a caller gives up, not an exotic path. The session-level bracket in `events.md` had the same gap and the same fix; `run()` in the same file already used `BaseException`, which is what made these two an inconsistency rather than a policy. The `except Exception` around the emit call itself is deliberately unchanged — swallowing `KeyboardInterrupt` there would let telemetry block a Ctrl-C. New `tests/test_skill_snippets.py` parses every fenced Python block in the skill and fails any handler that wraps an emit without catching `BaseException`, because a documented snippet is code an agent copies into a real loop and nothing else exercises it. (#702) + +- Add `sdk/python/tests/test_spool_creation.py`, which pins what the SDK is allowed to create now that it writes inside `~/.failproofai` — a directory the CLI and the daemon own. It asserts the EXACT set of paths that appear in each of the three machine states (spool present, home present, nothing present), because an assertion that "the events directory exists" passes just as happily when a `VERSION` file appears beside it. That file is the hazard: `detectLayout()` reads `VERSION`, `config.json`, `config.toml` and layout 1's seven markers to decide whether a home is `absent`, `current` or `stale`, and a `stale` verdict is what authorises `resetHome()` to delete. Verified against the real `detectLayout()` that a home holding only `custom-agents/` reports `{kind: "absent"}` with `isConfigured()` false — indistinguishable from a machine that has run nothing — and pinned from the SDK side so a regression fails here rather than in the CLI months later. Both halves are negative-controlled: stamping a `VERSION` file fails 5 tests, creating a sibling directory fails 6. (#702) + - **Retry `bun run build` on the release path, which is the half that never had a net.** `bun --bun next build` is a demonstrated flake, and on 2026-08-19 it proved it in the worst place: publishing v1.0.1, bun 1.3.14 took a SIGSEGV during the TypeScript phase — `oh no: Bun has crashed. This indicates a bug in Bun, not your code` — exited 132, and took `release-assets`, `publish`, `verify-install` and `announce` down with it, all skipped. Nothing about the code being released was wrong; the identical command had passed on the identical commit in `ci.yml` minutes earlier, which is the definition of a retryable failure. `ci.yml`'s `build` job has wrapped this in three attempts since it was written, and publish.yml's two `bun run build` steps — `cli-tarball`'s and `publish`'s — had none, so the path where a spurious failure costs the most was the one without protection. The second one matters more than the first: by the time `publish` builds, the release assets are already attached, so a crash there leaves a GitHub Release advertising daemon binaries whose npm package never shipped. Both are retried now, and `release-pipeline.test.ts` asserts it rather than trusting anyone to remember — a bare `run: bun run build` anywhere in publish.yml fails the suite. (#728) - **Stop CI paying for work it throws away, and stop a stalled apt mirror holding a release for six hours.** Four costs, found by measuring a green run rather than a red one. **`bun install` was running a full Next.js production build**: `package.json`'s `prepare` is `bun run build`, which bun fires as an install lifecycle hook, so six of `ci.yml`'s eight jobs spent ~28s (14s compile + 13s TypeScript) building an application they never read — and the `build` job did it twice, since its own Build step then re-ran the same thing warm in 7s. `rust-quality` has passed `--ignore-scripts` since it landed and installs in **one second**, which is the control that proves the rest; `translate-docs.yml` already guards the same way with a comment naming this exact hazard. Every install now does. **The cargo cache cost more to move than the work it replaced**: one `cargo-Linux-*` entry had reached **5,727 MB** — 57% of the repo's entire 10 GiB quota in a single key, which is the LRU-eviction pressure the previous fix here was about and did not remove — and restoring it took **127 seconds** against the 74s `cargo test` it existed to avoid. The cause is `path: target` taken literally: it archives every intermediate the workspace ever produced, including this workspace's own crates, which recompile in seconds and are the artifacts most likely to be stale. `Swatinem/rust-cache` caches the dependency artifacts and prunes the rest. **`rust-quality` ran in full on every pull request**, including the many that touch no Rust; its `Detect crates` gate was written for a stage-1 empty workspace and, with all three crates present, had been answering `true` unconditionally for months. It now also diffs the merge commit against its first parent, so it still reports a status — no `needs:` edge, nothing serialised behind it — while finishing in seconds on a TypeScript-only branch. `docs` gained the same gate, and its `mintlify` install is pinned to `4.2.680` to match `translate-docs.yml`, where floating meant an upstream release could redden a branch that changed nothing. **And 190 of 208 unit test files were building a jsdom they never touched**: the config set `environment: "jsdom"` globally for the sake of 16 React files and two more that already opt in per-file, and the `test` matrix runs the suite three times, so it was paid three times per run. Split into `node`/`dom` projects on the file extension, jsdom construction drops from **40.96s to 13.67s** measured locally, and a new `.test.tsx` still gets a DOM without anyone remembering to ask. The release hang is the same story in one step: **nothing in this repo had a job timeout except `integration-suite.yml`**, so when the linux-x64 daemon leg hit a stalled Azure mirror on 2026-08-19 it sat in `apt-get update` through three runner re-dispatches with v1.0.1 blocked behind it, while the arm64 leg ran the identical step in seconds. Every job across five workflows now declares one; the apt step is retried and given real acquire timeouts (its defaults are long enough to be no timeout at all against a stall) and loses its `-qq`, which was suppressing the one thing worth having in the log — which mirror stalled. `build-daemon.yml` also gains a concurrency group, scoped to `pull_request` so the `workflow_call` legs that *are* a release's binaries are never cancelled. `musl-tools` itself stays: `-p failproofaid` reaches `rusqlite` with `bundled` and `ring` through rustls, so the `cc` crate needs `musl-gcc` on both musl legs. **The bound is `sudo timeout`, not `nick-fields/retry`**, and that distinction is the whole fix rather than a style choice — the retry action was tried first and CI rejected it: it bounds a step by killing the process tree **as the runner user**, and apt runs as root, so the four-minute timeout fired exactly as designed and the action then died with `kill EPERM` instead of retrying, converting a recoverable stall into a failed leg. `timeout` inside the `sudo` makes the killer root too. The same run also showed the stall is real and not a one-off: every `azure.archive.ubuntu.com` line came back `Ign`, apt fell back to `archive.ubuntu.com`, fetched the InRelease files and then sat for three and a half minutes emitting nothing — so the step now tries `apt-get install` **before** `apt-get update` at all, since the refresh is the part that stalls and the runner image's package lists usually make it unnecessary. Dropping the `prepare` hook does cost the `test` job one thing it was silently getting: the custom-policy loader tests resolve `import ... from 'failproofai'` through `findDistIndex()` and need a real `dist/index.js`, so the job now builds that one bundle explicitly — three milliseconds against the ~28s it replaces. Worth recording how that surfaced, because the check that should have caught it did not: running the **whole suite** with `dist/` moved aside passes, since an earlier test writes the file before the loader tests read it, and only running them alone fails. Test-order luck read as a clean bill of health. None of these regressions turns CI red on its own either, which is why `release-pipeline.test.ts` now asserts all four — the timeouts, the `--ignore-scripts`, the absence of a bare `target` cache path, and an apt step that is bounded by a killer running as the same user apt does. Net: **~4.0 min wall and ~15.7 runner-minutes per pull request down to ~1.7 min and ~9**, with ~5 GiB of cache quota returned. (#726) @@ -44,6 +158,54 @@ - Give the nightly translation a voice when it fails, and a pulse when it does not run. It posted nothing by design — the reasoning being that its output is the pull request — which held for both success shapes and failed for the third: a run that dies also leaves no PR, so failing and idling produced the identical signal, none. Between 2026-08-11 and 2026-08-17 it opened nothing while 28 pages sat missing from 14 locales, and what noticed was a finding in the weekly docs audit rather than the job itself. Failure now posts to the same Slack webhook the other two jobs use, naming the step and carrying the log tail; success stays quiet, because a nightly "all good" is noise. Every exit also writes `last-run.json` into the work dir, and the weekly docs audit reports its AGE — the one failure no error handler can catch is the job never starting, and only a file's age can see that from outside. (#705) +- **`fp-cli`'s README said telemetry is on by default; it is off.** `TELEMETRY_DISABLED` has been `True` since before the rename, because the send path stalls every command ~5s when the analytics host is unreachable — the shutdown flush is bounded, the client build and first connect attempt are not. The README also asserted the exact opposite of that ("sending is time-bounded, so it never delays a command"), and it ships in the wheel to PyPI. It now says the feature is disabled and why, keeping the collection description as a review-in-advance section since re-enabling is one constant. (#702) + +- **The `fp-cli` skill stated half the credential ladder.** "`FP_API_KEY` takes precedence over `FP_TOKEN`" holds only between the two environment variables: `resolve_auth` checks the explicit `--token` flag *before* the ambient API key, so exporting `FP_API_KEY` in CI and also passing `--token` runs as that user's saved session, with their org memberships, rather than under the scoped key you meant to audit. A skill is instructions an agent executes, so a half-stated rule is one an agent acts on. All six rungs are now spelled out. (#702) + +- **A tool call and a hook sharing an id no longer cross-correlate in the SDK.** `_pending` namespaced its human and pause pairings but not its tool and hook ones, which keyed on the bare `tool_call_id` and `hook_id` — and those collide routinely, because both are frequently the harness's own step id. The `hook_completed` then consumed the `tool_use` timestamp and reported `duration_ms` for the interval between two unrelated events, while the real `tool_result` that followed got no duration at all. Two plausible numbers, no error, and nothing downstream able to tell. All four pair types are namespaced now; the keys never leave the process, so no wire format changed — only the fabricated duration, which becomes a correct one. (#702) + +- **An unusable `flush_interval` no longer kills the SDK's writer thread.** The interval wait runs before the flush loop's `try`, deliberately — wrapping it would make a failing flush retry at full speed instead of next cycle. The cost was that a negative, NaN or infinite interval raised out of the thread and killed it, and a dead writer is this class's worst state: `submit()` keeps accepting events, the queue keeps growing, nothing is ever written, and the caller learns none of it until the process exits and takes everything with it. Zero did not raise but busy-looped, pinning a core. All of them are now refused by `configure()` and `set_flush_interval()` before any state changes, so the caller gets a `ValueError` pointing at their own call. (#702) + +- **Two SDK event batches written in the same millisecond no longer overwrite each other.** Batch files were named from a millisecond timestamp alone, so two writes inside one millisecond produced the same filename and the second `os.replace` silently destroyed the first — no exception, no log line, no trace the events had existed. It fired on the atexit flush racing the flush thread (exactly when the last events of a run are written), on `flush_now()` from two threads, and worst of all across processes, since nothing in the name identified the writer and several agents sharing one spool root is the ordinary deployment. The stem now carries the pid and a per-process counter, matching what `fpai-collect`'s own batches already do; both daemons only ever required the `.jsonl` suffix. (#702) + +- **The SDK's cross-component spool test no longer skips in CI.** It gated every assertion in the file on a source path from the private agenteye repo, so all four skipped in a normal run — including three that assert nothing but the SDK's own resolution rule and need no other checkout at all. It now reads `crates/fpai-collect/src/config.rs` and `src/hooks/fp-home.ts` from this repo and never skips, with `FAILPROOFAI_SDK_REQUIRE_CONTRACT=1` in CI turning a moved file into a failure rather than a skip. The older AgentEye collector stays checkable via `FP_AGENTEYE_ROOT`. A guard that can silently degrade to a skip is not a guard. (#702) + +- **One unserializable payload no longer wedges the SDK's spool for the life of the process.** Serialisation was a single `json.dumps` over the whole drained batch, so one event that could not be encoded took every event beside it down: `_flush` returned the batch to the queue and re-raised, `_flush_loop` logged and retried the identical batch next interval, and that repeated forever. Everything emitted afterwards queued behind it, and the only outward sign was `Exception ignored in atexit callback` on the way out, which reads as a crash in the host application. `default=str` was never a defence — it is consulted for unsupported *values*, so it rescues datetime and UUID and does nothing for a non-str dict key or a reference cycle, which are the two shapes ordinary agent payloads actually arrive in (a tuple-keyed cache; an ORM row holding a back-reference). Encoding is per-entry now: strict first, so ordinary events are byte-identical to before, then a sanitised copy that coerces non-str keys and marks cycles, and only then is that ONE event dropped and logged. Encoding failures drop because retrying them is guaranteed to fail identically; filesystem failures still re-queue the whole batch, because those are transient. (#702) + +- **The SDK's in-memory queue is bounded.** `submit()` runs on the caller's agent loop and must never block or raise, so it cannot apply backpressure — which left an unbounded queue, and any condition that stopped the spool draining then turned a telemetry outage into an OOM kill of the host process: the SDK taking down the very agent it exists to observe. It is capped at 10_000 events now, matching `_PENDING_CAP`, discarding oldest-first and saying so in the log on the first drop and every thousandth after, because a stuck spool must not become the thing that fills the disk it is complaining about. (#702) + +- **The SDK's flush thread now survives `fork()`.** Threads do not, so a forked child inherited a queue, an atexit hook and nothing to drain either: `submit()` kept accepting, nothing was ever published, and the events appeared only if the child happened to exit through a normal interpreter shutdown. A prefork worker — gunicorn, celery, `multiprocessing`'s default start method on Linux — is killed instead, so telemetry from the workers, which is where all the work happens, silently never arrived. An `os.register_at_fork` handler restarts the thread in the child and rebuilds its `Event` and lock, both of which can be inherited held by a thread that no longer exists. It also discards the queue the child inherited: those events belong to the parent, which still holds them, and publishing from both produced a byte-identical duplicate of everything buffered at the instant of the fork. (#702) + +- **SDK tool and hook durations no longer correlate across sessions and agents.** The entry above namespaced `_pending` by what each pairing pairs; this is the other half. Human and pause pairings were keyed by session and agent from the start, tool and hook ones were not, and `_pending` lives on a single process-wide namespace — so a supervisor running agents concurrently, which is the ordinary multi-agent shape, collided on any step id that repeated across sessions. Starting `step-1` in session A and then in session B overwrote A's timestamp; A's result reported B's interval and B's result reported none. Both plausible, neither an error. The keys never leave the process, so again no wire format changed. (#702) + +- **The SDK refuses a promoted column value the server would silently store as NULL.** Ingest lifts `duration_ms`, `input_tokens` and `output_tokens` into unsigned 32-bit columns with `pu32()`, which returns None on a type mismatch — and None is written as NULL under a `200 OK`, so a string, a float, a bool or a negative arrived as an empty column with nothing logged and nothing rejected. `_RESERVED` never covered this: it blocks five structural keys and passes every other custom field through untouched, whatever it holds. All three are now type-checked wherever they can be set — as a custom field on any event, and as `model_response`'s own two parameters, which are the likeliest to arrive wrong since callers fill them straight from a provider's usage object. Rejected rather than coerced, at the boundary, where the caller still has a stack trace pointing at their own call. (#702) + +- **A new `flush_interval` applies to the cycle already waiting.** The writer thread starts at import, so its first wait was always the 500 ms default; a caller asking `configure()` for 50 ms had no way to know their first cycle would be ten times longer, and that window is long enough for a fork or an exit to land inside it. The loop waits on an `Event` rather than sleeping, and `set_flush_interval` now cuts the current wait short. (#702) + +- **A flush racing interpreter shutdown no longer loses the batch.** A batch is drained from the queue *before* it is written, so a flush thread stopped part-way through — which is what happens to a daemon thread once the interpreter starts finalising — took those events with it, leaving at most a stray `.tmp`. The atexit flush could not help because its emptiness check ran outside the lock: it saw an already-drained queue, returned immediately, and the dying thread kept the events. The check moved inside `_flush_lock`, so the final flush waits for an in-flight write instead of racing it; atexit callbacks run before threads are hung, so waiting is enough for it to finish normally. The atexit hook is also registered once at module scope over weak references rather than per writer — `atexit.register(self._flush)` stored a bound method and so made every writer immortal — and it swallows and logs its own exceptions, which is what was printing a traceback into the host agent's stderr during shutdown. (#702) + +- **`fp query update --sql @-` no longer saves an empty query.** `@-` is stdin, and stdin drains on the first read — the command read it twice, once to decide which fields changed and once to build the request body, so change detection compared the real text while the save wrote `""`. Exit 0, green card, query emptied. Read once into a local instead. (#702) + +- Emit the documented `{"cancelled": true}` envelope when `fp issues resolve` and `fp issues comment-delete` are declined under `--json`. Both docstrings promise it and the other ten write commands emit it; these two printed only the human stderr line, so a script reading stdout got an empty document at exit 0. (#702) + +- **Keep the customer's name out of the tripwire that exists to keep the customer's name out.** `test_no_customer_identifiers.py` spelled out the real tenant slug it denies, in a public repo, in a file that ships in the sdist — and excluded itself from its own scan, so nothing reported it. The customer entries are SHA-256 digests now, matched over token substrings so both the slug and the longer company name built from it still trip, and a failure names the file, the line and what class of identifier it is, never the identifier. Our own org names stay in the clear: they are in `LICENSE`, `SECURITY.md` and `package.json` already, and a contributor who trips over one needs to see which it was. (#702) + +- Grant `contents: read` in `publish-fp-cli.yml`. Naming any scope in a `permissions` block sets every unnamed one to `none` rather than leaving it at the default, so the job that only asked for `id-token: write` handed `actions/checkout` a token that cannot read this repository — and a comment two lines up asserted the opposite. (#702) + +- Bind the fp-cli PyPI publish to a `pypi-fp-cli` GitHub environment, since every guard in that workflow lives on the ref being dispatched — a writer could delete the actor allowlist and the `main` check on a branch and click Run, and OIDC mints a publishing token for whatever the workflow asks. The environment's deployment-branch rule lives in repo settings and its name in PyPI's publisher config; neither is reachable from a branch, and deleting the `environment:` line now fails the upload on a claim mismatch. Documented as required setup, because GitHub creates a missing environment implicitly and *without* protection rules. (#702) + +- Stop `sync-fp-cli-skill.yml` writing its PAT into `$WORKDIR/.git/config`. The credentialed clone URL persisted a token holding Contents write + Pull requests write on `FailproofAI/skills` into a workspace where the very next step runs `validate-skills.py` — a script fetched from that same repo. Both the clone and the push now authenticate through `git -c http.extraheader` (before the subcommand, so it is not persisted into the new repo's config) with the secret coming from `env:` rather than interpolated into the script body. (#702) + +- Add `__tests__/ci/fp-cli-workflows.test.ts`, the drift guard for all four workflow invariants above — the two that look redundant (`contents: read`, the environment name matching the header) are the two a cleanup would delete. (#702) + +### Dependencies + +- Bump `h2` 0.4.15 → 0.4.16 in `Cargo.lock`, clearing RUSTSEC-2026-0258, which turned the Supply Chain gate red on `main` and therefore on every branch cut from it — same shape as the `brace-expansion` and `next`/`sharp` incidents before it: the advisory published after main's last green scan, so nothing in this repo changed to cause it. `h2` is transitive through `hyper`, so the fix is a lockfile edit and nothing else. It is applied surgically rather than by `cargo update -p h2 --precise`, which additionally re-resolved six unrelated `windows-sys` edges *downward* (0.61.2 → 0.52.0/0.60.2) — churn that is invisible in CI, since the Rust jobs run on Linux and never compile those crates, and would have ridden into a release lockfile unreviewed. `cargo metadata --locked` accepts the result, so the resolver agrees the lock is complete and will not re-resolve behind it. (#717) + +## 1.0.1-beta.1 — 2026-08-16 + +### Fixes + - **Stop the dashboard server's telemetry from stranding its own events, and stop it printing `Error while flushing PostHog` while doing it.** Four options on the `posthog-node` client each disabled a different part of the library's delivery machinery, and together they turned a slow network into lost events plus a stack trace in the user's terminal — the one `failproofai audit` starts, where `launch()`'s log filter only strips the Server Action skew block. The injected `resilientFetch` was the root of it: it retried five times over ~40s and then returned a synthetic `200` so the library would never log a network error, but posthog-node does not merely hand its abort signal to an injected fetch, it **races that fetch against its own `requestTimeout`** (`Promise.race([fetchPromise, deadline])`) precisely because an injected fetch may ignore the signal — which ours did, by stripping it. A ~40s budget racing a 5s deadline can never return in time, so the synthetic `200` was unreachable code, the `console.error` it existed to prevent fired anyway at 5s, and the retries ran on detached from a client that had already given up. Worse, that `200` was the wrong answer even when it did land: posthog-node deliberately does NOT dequeue a batch that failed with a network error, so reporting success is what would have made it discard events that never arrived. The wrapper is gone; plain global fetch is what the library expects. `fetchRetryCount` was `0`, leaving that wrapper as the only thing retrying, at the wrong layer — the library retries inside a single flush, knows which errors are retryable, and keeps its queue coherent while doing it. `requestTimeout` was `5000`, half the library's own default, so every attempt had half the room. And `flushInterval` was `0`, which is falsy and therefore disables the flush timer outright — that is the one that actually stranded events, because the batch posthog-node retains after a network error then had nothing scheduled to resend it and sat in an in-memory queue (`PostHogMemoryStorage`, so nothing survives the process) until some unrelated later event happened to trigger a flush. `flushAt: 1` is unchanged and deliberate: volume is a handful of events per process, batching buys nothing, and sending immediately is the best defense a memory-only queue has against the process dying. Measured against a server that answers correctly but takes 6s — a slow network, not an outage — the old options delivered the event **four times** and logged two flush errors, because the wrapper re-POSTed the same batch on each of its own retries while the library still held its retained copy; the new ones deliver it **once**, with nothing logged. The exit drain is now idempotent, since `beforeExit` re-fires every time a handler schedules async work and an unguarded one started a fresh 30s `shutdown()` on each pass. **No event, trigger or property changed** — all 73 call sites across the three dispatchers fire exactly as before. (#701) - Cover telemetry delivery against the real `posthog-node` and a real socket, in `__tests__/lib/telemetry-delivery.test.ts`. The existing suite mocks the library wholesale, and that mock is what let the above live in the tree: the constructor was called with the right *shape*, so it passed while events were being stranded. The new tests assert on bytes that arrived over a socket — including gunzipping the batch body, without which a green test means nothing, since posthog-node gzips it and a raw read silently parses as "no events delivered". They pin that a captured event reaches `/batch/` with its properties intact, that a transient 500 is retried and still delivered, that a successful flush logs no error, and — for the hook dispatcher carrying the other 37 call sites — that `trackHookEvent` reaches `/capture/` and that `flushHookTelemetry` lands events the caller never awaited. Two facts they nail down rather than fix: posthog-node **overwrites `$lib`** with its own name on the server path, so `trackEvent`'s `"failproofai"` never lands and `product` is the attribution that actually survives (the raw-fetch hook dispatcher has no SDK to overwrite it, so its `"failproofai-hooks"` does); and the opt-out still sends nothing. (#701) diff --git a/CLAUDE.md b/CLAUDE.md index fee009122..50e26fdab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1208,7 +1208,46 @@ crates/failproofaid/ The daemon binary — socket server + service lif @failproofai/failproofaid-- npm packages and as GitHub Release assets — see "How the daemon binary reaches users") -__tests__/ Unit + e2e tests (vitest) +fp-cli/ The `fp` CLI for FailproofAI Cloud (Python, uv, pytest). + PyPI dist `fp-cli`; the installed command is `fp` — + they differ because `fp` was taken on PyPI. Talks only + to the Cloud dashboard's /api surface, never to the + Rust server directly. NOT the same thing as the + `failproofai` CLI this repo builds from bin/ + src/: + that one enforces inside the agent loop, this one + reads back what the loop did. + fp_cli/client.py Pure query layer (no printing, no Typer) — the surface + an MCP server would wrap + fp_cli/output.py All rendering, incl. `_TOP_LEVEL_GROUPS`, the + HAND-MAINTAINED top-level help table. A command missing + from it is invisible in `fp help` forever; guarded by + tests/test_help_table_coverage.py +sdk/python/ The telemetry SDK (Python, uv, pytest). PyPI dist + `failproofai-sdk`, imported as `failproofai_sdk`. The + OTHER end of the pipe from fp-cli: this one is called + BY the user's agent to record what it did, while fp-cli + reads that back. Zero runtime dependencies, by policy — + it installs into other people's agent processes, so any + dependency we declare is a constraint they inherit. + `sdk/` is a directory because more languages go beside + `python/`, not inside it. + failproofai_sdk/_events.py The 15 public event methods, and the `_pending` map that + auto-computes duration_ms by pairing start/end events + failproofai_sdk/_schema.py One dataclass per event type; `to_dict()` IS the wire + format. Frozen byte-for-byte by tests/test_wire_format.py + failproofai_sdk/_writer.py Background flush thread; publishes batches by writing + `.tmp` and atomically renaming to `.jsonl`. Importing the + package starts that thread — a documented side effect + failproofai_sdk/_resolver.py + Where the spool lives. Mirrors customAgentsDir() in + src/hooks/fp-home.ts and custom_agents_events_dir() in + crates/fpai-collect/src/config.rs; all three must agree + or the SDK writes where no daemon reads, with NO error + on either side. tests/test_spool_contract.py checks the + Rust and the TypeScript directly and never skips +__tests__/ Unit + e2e tests (vitest) — TypeScript only; the Python + components' tests live in fp-cli/tests/ and + sdk/python/tests/ and run under pytest examples/ Sample custom policy files ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 217bc4747..15de29b60 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,9 +76,23 @@ failproofai/ ├── scripts/ # Dev/start/build helper scripts ├── __tests__/ # Test files ├── examples/ # Example custom hook policies +├── fp-cli/ # The `fp` CLI for FailproofAI Cloud (Python; PyPI: fp-cli) +├── sdk/ # Client SDKs, one directory per language +│ └── python/ # PyPI: failproofai-sdk, imported as `failproofai_sdk` └── public/ # Static assets ``` +> `fp-cli/` and `sdk/python/` are the Python components in an otherwise +> TypeScript + Rust repo. Each is self-contained: its own `pyproject.toml`, its own +> `uv.lock`, its own pytest suite, its own CI job in `ci.yml`, and its own PyPI +> project. Neither is part of the npm package, the Next.js build, or the Cargo +> workspace, and both version independently of the root `package.json` — see +> CLAUDE.md. +> +> `sdk/` is a directory rather than a flat `failproofai-sdk/` because more +> languages are expected to land beside `python/`. They will be siblings, not +> nested inside it. + ### Key Subsystems | Directory | Description | diff --git a/__tests__/ci/failproofai-sdk-workflows.test.ts b/__tests__/ci/failproofai-sdk-workflows.test.ts new file mode 100644 index 000000000..6d8daed1b --- /dev/null +++ b/__tests__/ci/failproofai-sdk-workflows.test.ts @@ -0,0 +1,252 @@ +// @vitest-environment node +/** + * Drift guard for the two failproofai-sdk workflows, and a sibling of + * fp-cli-workflows.test.ts. Both files are hand-maintained and hold invariants a + * reviewer reading the diff would not see break: + * + * - publish-failproofai-sdk.yml grants `id-token: write` for Trusted Publishing. + * Naming any scope sets every unnamed one to `none`, so `contents: read` is what + * lets actions/checkout read the repo at all — it looks redundant and is not. + * - publishing is bound to a GitHub environment. Every other guard in that file (the + * actor allowlist, the ref check) lives on the ref being dispatched, so a writer + * could delete them on a branch and click Run; the environment's rules live in repo + * settings and in PyPI's publisher config, where a branch cannot reach them. The + * name has to match on both sides, so the header documents the same string. + * - the SDK installs with `--no-deps` in both the CI and publish smoke tests. That + * flag IS the zero-dependency assertion; dropping it turns the check into an + * ordinary install that would pass with a dependency silently added. + * - the test steps set FAILPROOFAI_SDK_REQUIRE_CONTRACT. Without it, + * test_spool_contract.py SKIPS when it cannot find the daemon sources instead of + * failing — which is exactly how its predecessor in the agenteye repo sat green + * and unverified. + * - sync-failproofai-sdk-skill.yml hands its PAT to git without writing it into + * $WORKDIR/.git/config, because the very next step runs a script fetched from the + * repo that PAT can write to. + * - the two skill syncs must not share a branch, label or concurrency group: each + * force-pushes its branch, so a shared one would overwrite the other's open PR. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { parse } from "yaml"; + +const ROOT = process.cwd(); + +function source(name: string): string { + return readFileSync(resolve(ROOT, ".github/workflows", name), "utf8"); +} + +function workflow(name: string): Record { + return parse(source(name)); +} + +/** Every `run:` script in a job, concatenated — for asserting on shell logic. */ +function runScripts(job: Record): string { + return (job.steps ?? []).map((s: Record) => s.run ?? "").join("\n"); +} + +const PYPI_ENVIRONMENT = "pypi-failproofai-sdk"; + +describe("publish-failproofai-sdk.yml", () => { + const job = workflow("publish-failproofai-sdk.yml").jobs.publish; + + it("grants contents: read alongside id-token: write", () => { + // Without it the token cannot read this repository and checkout fails. + expect(job.permissions).toMatchObject({ "contents": "read", "id-token": "write" }); + }); + + it("binds publishing to the environment its PyPI publisher is configured for", () => { + const env = typeof job.environment === "string" ? job.environment : job.environment?.name; + expect(env).toBe(PYPI_ENVIRONMENT); + // The same string has to be in PyPI's publisher config; the header is where a + // maintainer reads it off, so a rename that misses one side is caught here. + expect(source("publish-failproofai-sdk.yml")).toContain(`Environment: ${PYPI_ENVIRONMENT}`); + }); + + it("does not reuse fp-cli's PyPI environment", () => { + // Two projects sharing one environment means fp-cli's deployment rules would + // govern SDK releases, and PyPI would reject the mismatched claim anyway. + const fpCli = workflow("publish-fp-cli.yml").jobs.publish; + const other = typeof fpCli.environment === "string" ? fpCli.environment : fpCli.environment?.name; + expect(other).not.toBe(PYPI_ENVIRONMENT); + }); + + it("still refuses a non-main ref and a non-maintainer actor", () => { + const scripts = runScripts(job); + expect(scripts).toContain('if [ "$REF" != "main" ]'); + expect(scripts).toContain('if [ "$ACTOR" != "NiveditJain" ]'); + }); + + it("runs the tests and the installed-artifact smoke test before uploading", () => { + const steps: Record[] = job.steps ?? []; + const names = steps.map((s) => s.name ?? ""); + const upload = steps.findIndex((s) => (s.uses ?? "").startsWith("pypa/gh-action-pypi-publish")); + expect(upload).toBeGreaterThan(-1); + for (const gate of ["Test", "Smoke-test the artifact exactly as a user would receive it"]) { + expect(names.indexOf(gate)).toBeGreaterThan(-1); + expect(names.indexOf(gate)).toBeLessThan(upload); + } + }); + + it("requires the spool contract rather than letting it skip", () => { + const test = (job.steps ?? []).find((s: Record) => s.name === "Test"); + expect(test?.env?.FAILPROOFAI_SDK_REQUIRE_CONTRACT).toBe("1"); + }); + + it("installs the wheel with --no-deps, which is the zero-dependency assertion", () => { + expect(runScripts(job)).toContain("uv pip install --no-deps dist/*.whl"); + }); + + it("publishes from the SDK's own dist directory", () => { + // `packages-dir` is resolved from the repo root, NOT from the job's + // `working-directory` — so a bare `dist/` here would upload nothing, or + // whatever another component happened to leave at the root. + const upload = (job.steps ?? []).find((s: Record) => + (s.uses ?? "").startsWith("pypa/gh-action-pypi-publish"), + ); + expect(upload?.with?.["packages-dir"]).toBe("sdk/python/dist/"); + }); +}); + +describe("the failproofai-sdk CI job", () => { + const job = workflow("ci.yml").jobs["failproofai-sdk"]; + + it("exists and runs from the SDK directory", () => { + expect(job).toBeDefined(); + expect(job.defaults.run["working-directory"]).toBe("sdk/python"); + }); + + it("tests every Python version pyproject advertises", () => { + // Claiming >=3.10 and testing one of them is how a 3.10 user finds the break. + // Kept in step with `requires-python` and the classifiers by hand. + expect(job.strategy.matrix["python-version"]).toEqual(["3.10", "3.11", "3.12", "3.13", "3.14"]); + }); + + it("requires the spool contract rather than letting it skip", () => { + const test = (job.steps ?? []).find((s: Record) => s.name === "Test"); + expect(test?.env?.FAILPROOFAI_SDK_REQUIRE_CONTRACT).toBe("1"); + }); + + it("installs the built wheel with --no-deps and reads real events back", () => { + const scripts = runScripts(job); + expect(scripts).toContain("uv pip install --no-deps dist/*.whl"); + // Proving the artifact emits is the point; a bare `import` would pass on a + // package whose writer is broken. + expect(scripts).toContain("the installed wheel wrote no event batch"); + }); + + it("uses its own uv cache key, not fp-cli's", () => { + const setup = (job.steps ?? []).find((s: Record) => + (s.uses ?? "").startsWith("astral-sh/setup-uv"), + ); + expect(setup?.with?.["cache-dependency-glob"]).toBe("sdk/python/uv.lock"); + }); +}); + +describe("sync-failproofai-sdk-skill.yml", () => { + const name = "sync-failproofai-sdk-skill.yml"; + const text = source(name); + const job = workflow(name).jobs.sync; + + it("never puts the PAT in a remote URL", () => { + // A credentialed clone URL is persisted verbatim into $WORKDIR/.git/config, which the + // mirror repo's own validate-skills.py — run in that same workspace — could then read. + expect(text).not.toMatch(/https:\/\/[^\s"']*SKILLS_SYNC_PAT/); + }); + + it("authenticates git through an in-process header, from env, for both clone and push", () => { + const scripts = runScripts(job); + // `git -c` BEFORE the subcommand: `git clone -c ...` would write it into the new + // repo's config, which is the leak this avoids. (`\s` spans the line continuation.) + const authed = (verb: string) => + new RegExp(String.raw`git -c "http\.extraheader=AUTHORIZATION: basic \$\{AUTH\}"\s*\\?\s*` + verb); + expect(scripts).toMatch(authed("clone")); + expect(scripts).toMatch(authed("push")); + expect(scripts).toContain(`AUTH="$(printf 'x:%s' "$SKILLS_SYNC_PAT" | base64 -w0)"`); + expect(scripts).not.toContain("${{ secrets.SKILLS_SYNC_PAT }}"); // env:, not interpolated + }); + + it("keeps GITHUB_TOKEN read-only — every write goes through the PAT", () => { + expect(workflow(name).permissions).toEqual({ contents: "read" }); + }); + + it("detects a first sync with git status, not git diff", () => { + // The destination folder does not exist yet, so every mirrored file is UNTRACKED + // and `git diff` reports clean — a green run that shipped nothing. + expect(runScripts(job)).toContain('git status --porcelain -- "$DEST_SUBDIR"'); + }); + + it("shares no force-pushed branch, label or concurrency group with the fp-cli sync", () => { + const mine = workflow(name); + const theirs = workflow("sync-fp-cli-skill.yml"); + // Each run force-pushes BRANCH. Sharing it would overwrite the sibling's open PR + // with this skill's contents, and the sibling would never notice. + expect(mine.env.BRANCH).not.toBe(theirs.env.BRANCH); + expect(mine.env.DEST_SUBDIR).not.toBe(theirs.env.DEST_SUBDIR); + expect(mine.env.SRC).not.toBe(theirs.env.SRC); + expect(mine.env.LABEL).not.toBe(theirs.env.LABEL); + expect(mine.concurrency.group).not.toBe(theirs.concurrency.group); + }); + + it("mirrors from the SDK's skill directory", () => { + expect(workflow(name).env.SRC).toBe("sdk/python/skill"); + expect(workflow(name).env.DEST_SUBDIR).toBe("skills/failproofai-sdk"); + }); +}); + +describe("the failproofai-sdk-integrations CI job", () => { + // This job is the adapters' only automated evidence, and every invariant below + // is one a tidy-up would plausibly remove. Before it existed, all four + // integration modules skipped at import in every CI run — 168 test functions, + // green, never executed — because the frameworks live in extras that + // `--extra dev` does not install. + const job = () => workflow("ci.yml").jobs["failproofai-sdk-integrations"]; + + it("exists at all", () => { + expect(job()).toBeDefined(); + }); + + it("installs every framework extra", () => { + // Each adapter has exactly one extra. A missing one does not fail the install; + // it makes that adapter's module skip, which the env var below then catches — + // but only if the extra was meant to be there in the first place. + const install = job().steps.map((s: any) => s.run ?? "").join("\n"); + for (const extra of ["langchain", "langgraph", "crewai", "llamaindex", "pydantic-ai"]) { + expect(install).toContain(`--extra ${extra}`); + } + }); + + it("installs with --locked", () => { + // Without it, uv silently re-resolves and the job stops testing the versions + // the lockfile pins — the same drift `uv sync --locked` was adopted for. + const install = job().steps.map((s: any) => s.run ?? "").join("\n"); + expect(install).toContain("uv sync --locked"); + }); + + it("sets AGENTEYE_TESTS_REQUIRE_FRAMEWORKS so a skip is a failure", () => { + // THE load-bearing line. Drop it and a botched install reads as "4 skipped", + // the job passes having tested nothing, and the gap this job closed reopens + // in exactly the form that hid it the first time. + const step = job().steps.find((s: any) => s.env?.AGENTEYE_TESTS_REQUIRE_FRAMEWORKS); + expect(step, "no step sets AGENTEYE_TESTS_REQUIRE_FRAMEWORKS").toBeDefined(); + expect(String(step.env.AGENTEYE_TESTS_REQUIRE_FRAMEWORKS)).toBe("1"); + expect(step.run).toContain("tests/integrations"); + }); +}); + +describe("supply-chain registration", () => { + it("scans the SDK lockfile for vulnerabilities", () => { + // A lockfile absent from the scan args is silently unscanned — osv-scanner does + // not discover lockfiles on its own here. + expect(source("osv-scanner.yml")).toContain("--lockfile=sdk/python/uv.lock"); + }); + + it("gives the SDK its own dependabot entry", () => { + // dependabot resolves per directory; fp-cli's `uv` entry does not see this tree. + const config = parse(readFileSync(resolve(ROOT, ".github/dependabot.yml"), "utf8")); + const directories = config.updates + .filter((u: Record) => u["package-ecosystem"] === "uv") + .map((u: Record) => u.directory); + expect(directories).toContain("/sdk/python"); + }); +}); diff --git a/__tests__/ci/fp-cli-workflows.test.ts b/__tests__/ci/fp-cli-workflows.test.ts new file mode 100644 index 000000000..77f60ed62 --- /dev/null +++ b/__tests__/ci/fp-cli-workflows.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment node +/** + * Drift guard for the two fp-cli workflows. Both are hand-maintained, and both hold + * invariants that a reviewer reading the diff would not see break: + * + * - publish-fp-cli.yml grants `id-token: write` for Trusted Publishing. Naming any + * scope sets every unnamed one to `none`, so `contents: read` is what lets + * actions/checkout read the repo at all — it looks redundant and is not. + * - publishing is bound to a GitHub environment. Every other guard in that file (the + * actor allowlist, the ref check) lives on the ref being dispatched, so a writer + * could delete them on a branch and click Run; the environment's rules live in repo + * settings and in PyPI's publisher config, where a branch cannot reach them. The + * name has to match on both sides, so the header documents the same string. + * - sync-fp-cli-skill.yml hands its PAT to git without writing it into + * $WORKDIR/.git/config, because the very next step runs a script fetched from the + * repo that PAT can write to. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { parse } from "yaml"; + +const ROOT = process.cwd(); + +function source(name: string): string { + return readFileSync(resolve(ROOT, ".github/workflows", name), "utf8"); +} + +function workflow(name: string): Record { + return parse(source(name)); +} + +/** Every `run:` script in a job, concatenated — for asserting on shell logic. */ +function runScripts(job: Record): string { + return (job.steps ?? []).map((s: Record) => s.run ?? "").join("\n"); +} + +const PYPI_ENVIRONMENT = "pypi-fp-cli"; + +describe("publish-fp-cli.yml", () => { + const job = workflow("publish-fp-cli.yml").jobs.publish; + + it("grants contents: read alongside id-token: write", () => { + // Without it the token cannot read this repository and checkout fails. + expect(job.permissions).toMatchObject({ "contents": "read", "id-token": "write" }); + }); + + it("binds publishing to the environment its PyPI publisher is configured for", () => { + const env = typeof job.environment === "string" ? job.environment : job.environment?.name; + expect(env).toBe(PYPI_ENVIRONMENT); + // The same string has to be in PyPI's publisher config; the header is where a + // maintainer reads it off, so a rename that misses one side is caught here. + expect(source("publish-fp-cli.yml")).toContain(`Environment: ${PYPI_ENVIRONMENT}`); + }); + + it("still refuses a non-main ref and a non-maintainer actor", () => { + const scripts = runScripts(job); + expect(scripts).toContain('if [ "$REF" != "main" ]'); + expect(scripts).toContain('if [ "$ACTOR" != "NiveditJain" ]'); + }); + + it("runs the tests and the installed-artifact smoke test before uploading", () => { + const steps: Record[] = job.steps ?? []; + const names = steps.map((s) => s.name ?? ""); + const upload = steps.findIndex((s) => (s.uses ?? "").startsWith("pypa/gh-action-pypi-publish")); + expect(upload).toBeGreaterThan(-1); + for (const gate of ["Test", "Smoke-test the artifact exactly as a user would receive it"]) { + expect(names.indexOf(gate)).toBeGreaterThan(-1); + expect(names.indexOf(gate)).toBeLessThan(upload); + } + }); +}); + +describe("sync-fp-cli-skill.yml", () => { + const text = source("sync-fp-cli-skill.yml"); + const job = workflow("sync-fp-cli-skill.yml").jobs.sync; + + it("never puts the PAT in a remote URL", () => { + // A credentialed clone URL is persisted verbatim into $WORKDIR/.git/config, which the + // mirror repo's own validate-skills.py — run in that same workspace — could then read. + expect(text).not.toMatch(/https:\/\/[^\s"']*SKILLS_SYNC_PAT/); + }); + + it("authenticates git through an in-process header, from env, for both clone and push", () => { + const scripts = runScripts(job); + // `git -c` BEFORE the subcommand: `git clone -c ...` would write it into the new + // repo's config, which is the leak this avoids. (`\s` spans the line continuation.) + const authed = (verb: string) => + new RegExp(String.raw`git -c "http\.extraheader=AUTHORIZATION: basic \$\{AUTH\}"\s*\\?\s*` + verb); + expect(scripts).toMatch(authed("clone")); + expect(scripts).toMatch(authed("push")); + expect(scripts).toContain(`AUTH="$(printf 'x:%s' "$SKILLS_SYNC_PAT" | base64 -w0)"`); + expect(scripts).not.toContain("${{ secrets.SKILLS_SYNC_PAT }}"); // env:, not interpolated + expect(scripts).not.toContain("${{ secrets.SKILLS_SYNC_PAT }}"); // env:, not interpolated + }); + + it("keeps GITHUB_TOKEN read-only — every write goes through the PAT", () => { + expect(workflow("sync-fp-cli-skill.yml").permissions).toEqual({ contents: "read" }); + }); +}); diff --git a/__tests__/hooks/fp-home.test.ts b/__tests__/hooks/fp-home.test.ts index 54724e2fd..0dd3ea5cc 100644 --- a/__tests__/hooks/fp-home.test.ts +++ b/__tests__/hooks/fp-home.test.ts @@ -203,6 +203,12 @@ describe("HOME_CLASSES", () => { migrationLedgerFile: "migrationsDir", migrationBackupDir: "migrationsDir", stateDir: "stateDir", + // `fpcliDir` maps to ITSELF, for the reason `auditDir` does: the classified + // thing under it is the credential, not the directory. It holds exactly + // `cli-auth.json` today and the CLI may add a cache beside it, at which + // point a `user-typed` parent would protect a cache and a `derived` parent + // would delete a session. Classify the children. + fpcliDir: "fpcliDir", }; /** Every exported function that returns a path inside the home. */ diff --git a/crates/failproofaid/src/main.rs b/crates/failproofaid/src/main.rs index 51a3a456b..dbaf1f192 100644 --- a/crates/failproofaid/src/main.rs +++ b/crates/failproofaid/src/main.rs @@ -14,11 +14,70 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; +const USAGE: &str = "\ +failproofaid — the failproofai background daemon + +Usage: failproofaid [options] + +Options: + -h, --help Print this help and exit. + -v, --version Print the version and exit. + +Takes no positional arguments. With no options it runs in the foreground; the +installed service unit is what supervises it in normal use. Configuration is +read from ~/.failproofai (override with FAILPROOFAI_HOME). +"; + +/// What the command line asked for. +/// +/// Extracted from `main` so the fall-through below is testable. It is the whole +/// point of this enum: an unrecognised option used to reach `run()`, which takes +/// the singleton lock and binds two sockets, so `failproofaid --help` started a +/// daemon and printed nothing. In a terminal that reads as a hang; in a script it +/// blocks forever. +#[derive(Debug, PartialEq, Eq)] +enum Invocation { + Run, + Version, + Help, + /// An option this binary does not accept. Carries it so the error can name it. + Unknown(String), +} + +fn parse_args(args: &[String]) -> Invocation { + // --help and --version win over an unknown option that follows them, matching + // what every other CLI does: `--help --nonsense` prints help. + for a in args.iter().skip(1) { + if a == "--help" || a == "-h" { + return Invocation::Help; + } + if a == "--version" || a == "-v" { + return Invocation::Version; + } + } + match args.iter().skip(1).find(|a| a.starts_with('-')) { + Some(bad) => Invocation::Unknown(bad.clone()), + None => Invocation::Run, + } +} + fn main() { let args: Vec = std::env::args().collect(); - if args.iter().any(|a| a == "--version" || a == "-v") { - println!("failproofaid {}", env!("CARGO_PKG_VERSION")); - return; + match parse_args(&args) { + Invocation::Version => { + println!("failproofaid {}", env!("CARGO_PKG_VERSION")); + return; + } + Invocation::Help => { + print!("{USAGE}"); + return; + } + Invocation::Unknown(bad) => { + eprintln!("[failproofaid] unrecognised option: {bad}"); + eprint!("{USAGE}"); + std::process::exit(2); + } + Invocation::Run => {} } if let Err(err) = run() { @@ -734,6 +793,9 @@ fn collector_tasks() -> Vec { // One `Delivery` shared by both tasks, so they share an upload semaphore // and an in-flight set. Separate ones would let the watcher and a // concurrent sweep POST the same batch twice. + // Grabbed before the uploader moves into `Delivery`. The counters live on + // the `Uploader` itself so a supervised task restart never rewinds them. + let upload_metrics = uploader.metrics(); let delivery = std::sync::Arc::new(fpai_collect::Delivery::new(uploader)); let watch_delivery = delivery.clone(); @@ -750,6 +812,12 @@ fn collector_tasks() -> Vec { // daemon", where a stale file makes a stopped daemon look like a running // one whose sources all went quiet. let health = std::sync::Arc::new(fpai_collect::Health::new()); + // Before `install`, so the first snapshot already carries delivery. The + // source map alone cannot say whether anything is ARRIVING — a source's job + // ends at the spool — and the SDK's batches have no source entry at all, so + // a machine shipping only SDK events reported an empty, healthy-looking file + // whether ingest was storing every event or discarding all of them. + health.attach_delivery(upload_metrics); fpai_collect::health::install(health.clone()); let health_file = fpai_collect::health_path(&home); tasks.push(fpai_collect::TaskSpec::new("health", move |sd| { @@ -1604,6 +1672,49 @@ fn install_signal_handler(shutdown: Arc) { #[cfg(test)] mod tests { + use super::{Invocation, parse_args}; + + fn argv(rest: &[&str]) -> Vec { + std::iter::once("failproofaid".to_string()) + .chain(rest.iter().map(|s| s.to_string())) + .collect() + } + + #[test] + fn bare_invocation_runs_the_daemon() { + assert_eq!(parse_args(&argv(&[])), Invocation::Run); + } + + #[test] + fn version_flags_are_recognised() { + assert_eq!(parse_args(&argv(&["--version"])), Invocation::Version); + assert_eq!(parse_args(&argv(&["-v"])), Invocation::Version); + } + + #[test] + fn help_flags_are_recognised() { + // The regression this file exists for: --help used to fall through to + // run(), so it started the daemon, took the lock and printed nothing. + assert_eq!(parse_args(&argv(&["--help"])), Invocation::Help); + assert_eq!(parse_args(&argv(&["-h"])), Invocation::Help); + } + + #[test] + fn an_unknown_option_never_starts_the_daemon() { + assert_eq!( + parse_args(&argv(&["--collect"])), + Invocation::Unknown("--collect".to_string()) + ); + } + + #[test] + fn help_wins_over_a_later_unknown_option() { + assert_eq!( + parse_args(&argv(&["--help", "--nonsense"])), + Invocation::Help + ); + } + use super::*; #[test] diff --git a/crates/failproofaid/tests/collector_reload_e2e.rs b/crates/failproofaid/tests/collector_reload_e2e.rs index 23cf8c8c2..017badcd1 100644 --- a/crates/failproofaid/tests/collector_reload_e2e.rs +++ b/crates/failproofaid/tests/collector_reload_e2e.rs @@ -256,22 +256,57 @@ fn disabling_collection_stops_it_and_re_enabling_starts_it_again() { // `--disconnect` used to require a restart to take effect, so a machine that // had left its organisation went on shipping. The reverse matters just as // much: re-enabling must not need one either, or the fix is half a fix. + // + // The lever is the CREDENTIAL, because that is what `--disconnect` actually + // removes (`clearIngestCredential` in cloud-enrollment-cli.ts). This used to + // toggle `collector.hooks` as a stand-in, which no longer disables anything: + // that flag gates the daemon's own hook-activity source and deliberately + // does NOT gate delivery, since the spool also carries batches the + // `failproofai-sdk` wrote from the user's own process. Toggling the real + // thing is a stronger test of the scenario this exists for, and the + // companion test below pins the behaviour that replaced the old lever. let home = unique_home("toggle"); make_home(&home, "a-stable-key"); let daemon = spawn_daemon(&home); daemon.wait_for("collector enabled", 1, Duration::from_secs(20)); + let creds = home.join("credentials.json"); + let on = std::fs::read_to_string(&creds).unwrap(); + std::fs::remove_file(&creds).unwrap(); + daemon.wait_for("no longer enabled", 1, Duration::from_secs(20)); + + std::fs::write(&creds, &on).unwrap(); + daemon.wait_for("collector enabled", 2, Duration::from_secs(20)); + let _ = std::fs::remove_dir_all(&home); +} + +#[test] +fn turning_both_capture_sources_off_leaves_delivery_running() { + // The counterweight to the change above, and the bug it came from: with + // `sessions` and `hooks` both false the daemon used to start NOTHING — no + // spool watcher, no sweeper, no log line — so every batch the SDK wrote + // sat on disk forever, with no error on either side and an unread spool + // indistinguishable from an idle one. + // + // Those two settings gate the daemon's own capture sources, and each is + // checked again where its source is registered, so leaving them off still + // starts neither. What they must not gate is delivery. + let home = unique_home("sources-off"); + make_home(&home, "a-stable-key"); let cfg = home.join("config.json"); let on = std::fs::read_to_string(&cfg).unwrap(); - // JSON now, so the edit is on the key/value pair, not a TOML line. A - // string replace that silently matches nothing writes the file back - // unchanged and the test then waits 20s for a reload that never had a - // reason to happen — which is exactly how this broke. std::fs::write(&cfg, on.replace(r#""hooks":true"#, r#""hooks":false"#)).unwrap(); - daemon.wait_for("no longer enabled", 1, Duration::from_secs(20)); - std::fs::write(&cfg, &on).unwrap(); - daemon.wait_for("collector enabled", 2, Duration::from_secs(20)); + let daemon = spawn_daemon(&home); + daemon.wait_for("collector enabled", 1, Duration::from_secs(20)); + // The watcher is the thing that ships an SDK batch; without it this daemon + // is a process that reports healthy and delivers nothing. + daemon.wait_for("spool watcher started", 1, Duration::from_secs(20)); + assert!( + !daemon.stderr().contains("hook-activity source started"), + "hooks=false must still switch the daemon's own hook source off:\n{}", + daemon.stderr() + ); let _ = std::fs::remove_dir_all(&home); } diff --git a/crates/fpai-collect/src/config.rs b/crates/fpai-collect/src/config.rs index 87c2ca2de..6acb75e80 100644 --- a/crates/fpai-collect/src/config.rs +++ b/crates/fpai-collect/src/config.rs @@ -261,11 +261,25 @@ pub struct CollectorConfig { } impl CollectorConfig { - /// True when there is a usable credential AND at least one stream enabled. - /// This is what `collector_tasks()` keys off, so an unconfigured machine - /// starts no thread and no runtime. + /// True when there is a usable credential. This is what `collector_tasks()` + /// keys off, so an unconfigured machine starts no thread and no runtime. + /// + /// Deliberately NOT `&& (sessions || hooks)`. Those two gate the daemon's + /// OWN capture sources — CLI session transcripts and hook activity — and + /// each is checked again where its source is registered, so leaving them + /// off still starts neither. What they must not gate is DELIVERY, because + /// the spool also carries batches this daemon did not produce: the + /// `failproofai-sdk` writes its own events into `custom-agents/events/`, + /// and the spool watcher is the only thing that ships them. + /// + /// While they did gate it, `collector.hooks = false` — a documented + /// privacy choice, and the only one available to somebody who wants their + /// instrumented agents shipped and nothing else — silently disabled the + /// SDK too: no task started, no line logged, and batches accumulated in + /// the spool forever. An unread spool is indistinguishable from an idle + /// one, which is the exact failure this project exists to remove. pub fn is_enabled(&self) -> bool { - self.ingest.is_some() && (self.settings.sessions || self.settings.hooks) + self.ingest.is_some() } } diff --git a/crates/fpai-collect/src/health.rs b/crates/fpai-collect/src/health.rs index 281299333..05288a6ec 100644 --- a/crates/fpai-collect/src/health.rs +++ b/crates/fpai-collect/src/health.rs @@ -55,6 +55,37 @@ pub struct SourceHealth { pub errors: u64, } +/// What delivery reports about itself, across every source AND the SDK spool. +/// +/// The source map above cannot answer "is anything actually arriving", because +/// a source's job ends when it writes a batch to the spool. Everything after +/// that — the POST, the server's verdict, the parking of what would not go — is +/// invisible to it, and the SDK's own batches have no source entry at all: the +/// `failproofai-sdk` writes them straight into the spool from the user's +/// process, so a machine shipping nothing but SDK events reports a perfectly +/// healthy, entirely empty `sources` map. +/// +/// `skipped` is the one worth staring at. Ingest answers `200` with +/// `{"accepted":N,"skipped":M}` and the daemon deletes the batch either way, so +/// a systematically malformed field — an `environment` containing a comma, say +/// — discards every event on the machine while every layer reports success. +/// Before this, the only trace was a line in the daemon's log. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct DeliveryHealth { + /// Events the server said it stored, since this daemon started. + pub accepted: u64, + /// Events the server refused as malformed. Non-zero means data loss that + /// nothing else on the machine will tell you about. + pub skipped: u64, + /// Batches answered `200` while storing NONE of their events — the shape a + /// systematic problem takes, as opposed to one bad line. + pub batches_fully_skipped: u64, + /// Unix seconds of the last upload the server accepted. Zero means not one + /// has succeeded since startup, which on a machine that is producing events + /// is the loudest thing in this file. + pub last_ok_ts: u64, +} + /// The whole record, as written. #[derive(Debug, Default, Deserialize, Serialize)] pub struct HealthFile { @@ -62,6 +93,11 @@ pub struct HealthFile { /// current one — a daemon that died leaves its last record behind. pub ts: u64, pub sources: BTreeMap, + /// Absent when this daemon has no uploader, which is every daemon with no + /// credential. Skipped rather than zeroed: all-zero counters and "delivery + /// is not configured" are different facts and must not render the same. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delivery: Option, } /// Shared, cheap-to-update health state. @@ -73,6 +109,9 @@ pub struct HealthFile { pub struct Health { sources: Mutex>, writes: AtomicU64, + /// Set once at startup when there is an uploader. Read, never written, so + /// the counters stay owned by the `Uploader` that survives a task restart. + delivery: Mutex>>, } impl Health { @@ -109,12 +148,28 @@ impl Health { entry.last_error = Some(truncate(error, MAX_ERROR_LEN)); } + /// Report delivery counters alongside the sources. Called once, at startup. + pub fn attach_delivery(&self, metrics: std::sync::Arc) { + if let Ok(mut slot) = self.delivery.lock() { + *slot = Some(metrics); + } + } + /// Snapshot for writing. pub fn snapshot(&self) -> HealthFile { let sources = self.sources.lock().map(|m| m.clone()).unwrap_or_default(); + let delivery = self.delivery.lock().ok().and_then(|slot| { + slot.as_ref().map(|m| DeliveryHealth { + accepted: m.accepted_total.load(Ordering::Relaxed), + skipped: m.skipped_total.load(Ordering::Relaxed), + batches_fully_skipped: m.batches_fully_skipped.load(Ordering::Relaxed), + last_ok_ts: m.last_ok_ts.load(Ordering::Relaxed), + }) + }); HealthFile { ts: now_secs(), sources, + delivery, } } diff --git a/crates/fpai-collect/src/lib.rs b/crates/fpai-collect/src/lib.rs index f6d50a651..4ae27186e 100644 --- a/crates/fpai-collect/src/lib.rs +++ b/crates/fpai-collect/src/lib.rs @@ -31,7 +31,7 @@ pub use config::{ }; pub use delivery::Delivery; pub use extra_paths::{ExtraPath, Resolved as ResolvedExtraPaths}; -pub use health::{Health, HealthFile, SourceHealth, health_path}; +pub use health::{DeliveryHealth, Health, HealthFile, SourceHealth, health_path}; pub use spool::SpoolWriter; pub use supervisor::{ CollectorHandle, DEFAULT_FLUSH_BUDGET, Shutdown, SupervisorMetrics, TaskError, TaskSpec, diff --git a/crates/fpai-collect/src/uploader.rs b/crates/fpai-collect/src/uploader.rs index 6b60cc395..6f748b8a2 100644 --- a/crates/fpai-collect/src/uploader.rs +++ b/crates/fpai-collect/src/uploader.rs @@ -70,6 +70,12 @@ pub enum UploadError { attempts: u32, detail: String, }, + /// A 2xx whose ack says the server stored NONE of the batch. Not a + /// success: the events are not on the server and this file is their last + /// copy, so it is parked rather than deleted. + StoredNothing { + skipped: u64, + }, Io(std::io::Error), } @@ -80,6 +86,12 @@ impl std::fmt::Display for UploadError { UploadError::Server { status, attempts } => { write!(f, "server error {status} after {attempts} attempt(s)") } + UploadError::StoredNothing { skipped } => { + write!( + f, + "the server stored none of this batch ({skipped} skipped)" + ) + } UploadError::Network { attempts, detail } => { write!(f, "network error after {attempts} attempt(s): {detail}") } @@ -246,7 +258,27 @@ impl Uploader { // identically until the URL is fixed. match resp.json::().await { Ok(ack) => { - self.record_ack(path, &ack); + // `record_ack`'s own contract: "A 200 that stored + // nothing is an error, not a success." It said so + // and then returned Ok anyway, so `upload_file` + // deleted the file — the module's stated invariant + // is that `failed/` is a retry queue and a batch + // the server does not have is "never deleted", and + // this was the one path that broke it. + // + // Parked retryable (client_status None), not + // poison-on-sight: the observed cause was an + // intermediary mangling an oversized body, which a + // retry can survive. `park_inner` bounds that — + // attempt is encoded in the filename and becomes + // `.poison` at `failed_retries_max`, after which it + // is kept forever and never retried again. + if self.record_ack(path, &ack) { + self.park(path, None, attempt).await; + return Err(UploadError::StoredNothing { + skipped: ack.skipped, + }); + } return Ok(()); } Err(_) => { @@ -293,7 +325,7 @@ impl Uploader { /// Interpret the ack body. A 200 that stored nothing is an error, not a /// success — it is the shape a systematically malformed transform takes, /// and without this it looks identical to a healthy upload. - fn record_ack(&self, path: &Path, ack: &IngestAck) { + fn record_ack(&self, path: &Path, ack: &IngestAck) -> bool { self.metrics .accepted_total .fetch_add(ack.accepted, Ordering::Relaxed); @@ -322,6 +354,8 @@ impl Uploader { "the server skipped some events in this batch" ); } + + ack.accepted == 0 && ack.skipped > 0 } /// `base * 2^(attempt-1)` plus jitter. diff --git a/crates/fpai-collect/tests/config.rs b/crates/fpai-collect/tests/config.rs index b44497d63..984d4826e 100644 --- a/crates/fpai-collect/tests/config.rs +++ b/crates/fpai-collect/tests/config.rs @@ -79,6 +79,39 @@ fn a_key_alone_does_not_enable_session_collection() { fs::remove_dir_all(&home).ok(); } +#[test] +fn a_key_with_both_sources_off_still_delivers_the_sdk_spool() { + // `collector.hooks = false` is a documented privacy choice. It must switch + // off the daemon's own hook-activity source and NOTHING else: the spool it + // watches also holds batches written by `failproofai-sdk` from the user's + // own instrumented agents, and the watcher is the only thing that ships + // them. While this returned false for that config the daemon started no + // task and logged no line, and those batches piled up forever. + let home = tmp_home("bothoff"); + config::write_ingest( + &home, + &Ingest { + url: DEFAULT_INGEST_URL.into(), + key: "k".into(), + }, + ) + .unwrap(); + fs::write( + home.join("config.json"), + r#"{"collector":{"sessions":false,"hooks":false}}"#, + ) + .unwrap(); + + let cfg = without_env_overrides(|| config::load(&home).unwrap()); + assert!(!cfg.settings.sessions); + assert!(!cfg.settings.hooks); + assert!( + cfg.is_enabled(), + "delivery must run for the SDK spool even with both capture sources off" + ); + fs::remove_dir_all(&home).ok(); +} + #[test] fn the_credential_file_is_written_owner_only() { #[cfg(unix)] diff --git a/crates/fpai-collect/tests/health_delivery.rs b/crates/fpai-collect/tests/health_delivery.rs new file mode 100644 index 000000000..2873686e8 --- /dev/null +++ b/crates/fpai-collect/tests/health_delivery.rs @@ -0,0 +1,91 @@ +//! The health file must be able to say whether anything is ARRIVING. +//! +//! Its source map cannot. A source's job ends when it writes a batch into the +//! spool — the POST, the server's verdict and the parking of what would not go +//! are all after that — and the SDK's batches have no source entry at all, +//! because `failproofai-sdk` writes them into the spool from the user's own +//! process. So a machine shipping nothing but SDK events produced a file with +//! an empty, perfectly healthy `sources` map whether ingest was storing every +//! event or discarding all of them. + +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use fpai_collect::{Health, HealthFile, UploadMetrics}; + +#[test] +fn delivery_is_absent_until_an_uploader_attaches() { + // All-zero counters and "this daemon has no credential, so nothing is being + // delivered at all" are different facts. Rendering them the same would make + // an unconfigured machine look like a broken one, and vice versa. + let health = Health::new(); + let json = serde_json::to_string(&health.snapshot()).unwrap(); + assert!( + !json.contains("delivery"), + "an unconfigured daemon must omit the section, not zero it: {json}" + ); +} + +#[test] +fn attached_delivery_counters_reach_the_snapshot() { + let health = Health::new(); + let metrics = Arc::new(UploadMetrics::default()); + health.attach_delivery(metrics.clone()); + + metrics.accepted_total.fetch_add(120, Ordering::Relaxed); + metrics.skipped_total.fetch_add(7, Ordering::Relaxed); + metrics + .batches_fully_skipped + .fetch_add(1, Ordering::Relaxed); + metrics.last_ok_ts.store(1_787_216_518, Ordering::Relaxed); + + let snap = health.snapshot(); + let delivery = snap + .delivery + .expect("delivery must be reported once attached"); + assert_eq!(delivery.accepted, 120); + assert_eq!( + delivery.skipped, 7, + "skipped is the counter that means data loss" + ); + assert_eq!(delivery.batches_fully_skipped, 1); + assert_eq!(delivery.last_ok_ts, 1_787_216_518); +} + +#[test] +fn the_counters_are_read_live_rather_than_copied_at_attach() { + // The `Uploader` owns them and outlives any supervised task restart, so the + // health writer must read through to it. Snapshotting the values at attach + // time would freeze the file at "nothing has happened yet" forever — which + // reads exactly like a healthy idle machine. + let health = Health::new(); + let metrics = Arc::new(UploadMetrics::default()); + health.attach_delivery(metrics.clone()); + assert_eq!(health.snapshot().delivery.unwrap().accepted, 0); + + metrics.accepted_total.fetch_add(5, Ordering::Relaxed); + assert_eq!(health.snapshot().delivery.unwrap().accepted, 5); +} + +#[test] +fn a_written_file_round_trips_through_the_published_type() { + // `HealthFile` is what a reader outside this crate deserializes; an added + // field that only serializes one way would be invisible to them. + let dir = std::env::temp_dir().join(format!("fpai-health-{}", std::process::id())); + let path = dir.join("collector-health.json"); + let health = Health::new(); + let metrics = Arc::new(UploadMetrics::default()); + metrics.skipped_total.fetch_add(3, Ordering::Relaxed); + health.attach_delivery(metrics); + health.write(&path).unwrap(); + + let parsed: HealthFile = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(parsed.delivery.expect("round trip").skipped, 3); + + // An older file, written before this section existed, must still parse. + let legacy: HealthFile = serde_json::from_str(r#"{"ts":1,"sources":{}}"#).unwrap(); + assert!(legacy.delivery.is_none()); + + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/fpai-collect/tests/uploader.rs b/crates/fpai-collect/tests/uploader.rs index e187ed3bb..58017f458 100644 --- a/crates/fpai-collect/tests/uploader.rs +++ b/crates/fpai-collect/tests/uploader.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use fpai_collect::Uploader; +use fpai_collect::{UploadError, Uploader}; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -106,13 +106,30 @@ async fn a_200_that_stored_nothing_is_counted_as_fully_skipped() { let batch = write_batch(&spool, "claude-s-1-0.jsonl", 5); let up = uploader(&server, &failed); - up.upload_file(&batch).await.unwrap(); + // It used to return Ok here, so `upload_file` deleted the batch: the events + // were not on the server, this file was their last copy, and it went in the + // bin behind one ERROR line in the daemon's own log. That contradicted this + // module's stated invariant — `failed/` is a retry queue and such a batch is + // "never deleted" — and it is the whole reason the ack body is read at all. + let err = up.upload_file(&batch).await.unwrap_err(); + assert!( + matches!(err, UploadError::StoredNothing { skipped: 5 }), + "expected StoredNothing, got {err:?}" + ); let m = up.metrics(); assert_eq!(m.batches_fully_skipped.load(Ordering::Relaxed), 1); assert_eq!(m.skipped_total.load(Ordering::Relaxed), 5); assert_eq!(m.accepted_total.load(Ordering::Relaxed), 0); + // The data survives, in failed/, rather than being deleted. + assert!( + !batch.exists(), + "the batch should have moved out of the spool" + ); + let parked: Vec<_> = fs::read_dir(&failed).unwrap().flatten().collect(); + assert_eq!(parked.len(), 1, "the batch should be parked, not deleted"); + fs::remove_dir_all(&spool).ok(); fs::remove_dir_all(&failed).ok(); } diff --git a/docs-old/agenteye/python-sdk-skill.mdx b/docs-old/agenteye/python-sdk-skill.mdx index 8d102cfb5..76f285b04 100644 --- a/docs-old/agenteye/python-sdk-skill.mdx +++ b/docs-old/agenteye/python-sdk-skill.mdx @@ -48,7 +48,7 @@ They hand off in that order: this skill gets events flowing, the evaluator score ## Prerequisites 1. **Python 3.10+** and the agent codebase you want to instrument. -2. **The SDK.** It is distributed to customers as a private wheel rather than from a public index — your onboarding covers how to get it and install it. The skill knows the install path and will ask you rather than guess if it cannot find it. +2. **The SDK.** `pip install failproofai-sdk` (or `uv add failproofai-sdk`). The skill knows the install path, including the one command to avoid — `pip install agenteye` resolves to a stranded release of an older CLI, not to this SDK. 3. **Nothing else.** No dashboard login, no API key, no network. The skill verifies against the event files the SDK writes, so it can finish and prove its work offline. ## Where to get it diff --git a/docs-old/agenteye/python-sdk.mdx b/docs-old/agenteye/python-sdk.mdx index c8657a174..e231c7cbd 100644 --- a/docs-old/agenteye/python-sdk.mdx +++ b/docs-old/agenteye/python-sdk.mdx @@ -18,14 +18,30 @@ Under the hood, the SDK writes structured events to local JSONL files, and the c ## Installation -The SDK is distributed to customers as a private wheel rather than from a public package index. Your onboarding covers how to obtain it, install it, and pin it — talk to your Failproof AI contact if you need access. +```bash +pip install failproofai-sdk +``` + +Or with `uv`: + +```bash +uv add failproofai-sdk +``` + +The distribution is `failproofai-sdk`; the import is `failproofai_sdk`. It has no dependencies, so it cannot conflict with anything already in your agent's environment. + + + **Do not `pip install agenteye`.** That name on PyPI belongs to a stranded release of an older CLI, not to this SDK. Installing it gives you `ModuleNotFoundError` on `import failproofai_sdk` — and if you are upgrading from the SDK's own pre-rename releases, which also published under `agenteye`, pip treats it as an upgrade and removes the SDK. + Once it is installed, confirm you have it: ```bash -python -c "import agenteye; print(agenteye.__version__)" +python -c "import failproofai_sdk; print(failproofai_sdk.__version__)" ``` +Upgrading an existing integration? Uninstall `agenteye`, install `failproofai-sdk`, and change `import agenteye` to `import failproofai_sdk`. Nothing else moves: every method, argument, and emitted field is identical, and so is everything on disk — the spool is still `~/.agenteye/` and `AGENTEYE_HOME` still overrides it. + Prefer to let a coding agent do the whole integration? The [Python SDK Agent Skill](/agenteye/python-sdk-skill) knows the install path, plans the instrumentation points, writes them, and verifies the events land. --- @@ -33,13 +49,13 @@ Prefer to let a coding agent do the whole integration? The [Python SDK Agent Ski ## Quick Start ```python -import agenteye +import failproofai_sdk -agenteye.configure(environment="production") +failproofai_sdk.configure(environment="production") -agenteye.event.agent_start(session_id="run-001", agent_id="planner", goal="answer user query") +failproofai_sdk.event.agent_start(session_id="run-001", agent_id="planner", goal="answer user query") -agenteye.event.tool_use( +failproofai_sdk.event.tool_use( session_id="run-001", agent_id="planner", tool_name="web_search", @@ -47,7 +63,7 @@ agenteye.event.tool_use( input={"query": "latest AI research"}, ) -agenteye.event.tool_result( +failproofai_sdk.event.tool_result( session_id="run-001", agent_id="planner", tool_name="web_search", @@ -55,7 +71,7 @@ agenteye.event.tool_result( output={"results": ["..."]}, ) -agenteye.event.agent_end(session_id="run-001", agent_id="planner", outcome="success") +failproofai_sdk.event.agent_end(session_id="run-001", agent_id="planner", outcome="success") ``` ### Instrumenting a real call @@ -64,14 +80,14 @@ In practice you wrap your existing agent code. Bracket a model call with `model_ ```python import anthropic -import agenteye +import failproofai_sdk -agenteye.configure(environment="production") +failproofai_sdk.configure(environment="production") client = anthropic.Anthropic() messages = [{"role": "user", "content": "Summarise today's incidents."}] -agenteye.event.model_request( +failproofai_sdk.event.model_request( session_id="run-001", agent_id="planner", model="claude-sonnet-4-6", @@ -84,7 +100,7 @@ reply = client.messages.create( messages=messages, ) -agenteye.event.model_response( +failproofai_sdk.event.model_response( session_id="run-001", agent_id="planner", model=reply.model, @@ -106,7 +122,7 @@ Here is what those events look like once they reach the dashboard, colour-coded ## configure() ```python -agenteye.configure( +failproofai_sdk.configure( base_dir=None, # Path | str | None. Default: $AGENTEYE_HOME or ~/.agenteye flush_interval=0.5, # float, seconds between flush cycles environment=None, # str | None. Deployment environment label @@ -129,7 +145,7 @@ Label every event with a deployment environment (`production`, `staging`, `qa`, **Option 1: via `configure()`:** ```python -agenteye.configure(environment="production") +failproofai_sdk.configure(environment="production") ``` **Option 2: via environment variable:** @@ -176,7 +192,7 @@ All methods also accept arbitrary `**kwargs` for custom metadata (see [Custom Fi Emitted when an agent begins work. ```python -agenteye.event.agent_start( +failproofai_sdk.event.agent_start( session_id="run-001", agent_id="planner", goal="answer user query", # str | None @@ -191,7 +207,7 @@ agenteye.event.agent_start( Emitted when an agent finishes work. ```python -agenteye.event.agent_end( +failproofai_sdk.event.agent_end( session_id="run-001", agent_id="planner", outcome="success", # str | None @@ -206,7 +222,7 @@ agenteye.event.agent_end( Emitted when an agent invokes a tool. Pair with `tool_result`; the SDK auto-computes `duration_ms`. ```python -agenteye.event.tool_use( +failproofai_sdk.event.tool_use( session_id="run-001", agent_id="planner", tool_name="web_search", # str, required @@ -222,7 +238,7 @@ agenteye.event.tool_use( Emitted when a tool returns. Correlates with `tool_use` via `tool_call_id`. ```python -agenteye.event.tool_result( +failproofai_sdk.event.tool_result( session_id="run-001", agent_id="planner", tool_name="web_search", @@ -240,7 +256,7 @@ agenteye.event.tool_result( Emitted just before sending a prompt to an LLM. ```python -agenteye.event.model_request( +failproofai_sdk.event.model_request( session_id="run-001", agent_id="planner", model="claude-sonnet-4-6", # str | None - any provider/model string; not validated @@ -263,7 +279,7 @@ agenteye.event.model_request( Emitted when the LLM returns a response. ```python -agenteye.event.model_response( +failproofai_sdk.event.model_response( session_id="run-001", agent_id="planner", model="claude-sonnet-4-6", # str | None - any provider/model string; not validated @@ -286,7 +302,7 @@ agenteye.event.model_response( Emitted when a hook fires. Pair with `hook_completed`; the SDK auto-computes `duration_ms`. ```python -agenteye.event.hook_triggered( +failproofai_sdk.event.hook_triggered( session_id="run-001", agent_id="planner", hook_name="pre_tool_use", # str, required @@ -303,7 +319,7 @@ agenteye.event.hook_triggered( Emitted when a hook finishes. Correlates with `hook_triggered` via `hook_id`. ```python -agenteye.event.hook_completed( +failproofai_sdk.event.hook_completed( session_id="run-001", agent_id="planner", hook_name="pre_tool_use", @@ -322,7 +338,7 @@ agenteye.event.hook_completed( Emitted when an unhandled error occurs. ```python -agenteye.event.error( +failproofai_sdk.event.error( session_id="run-001", agent_id="planner", error_type="TimeoutError", # str, required @@ -342,7 +358,7 @@ Human-in-the-loop events give you oversight over the moments where a person step Emitted when the agent pauses execution to wait for a human to provide input. Pair with `human_input`; the SDK auto-computes `duration_ms` (how long the human took to respond). ```python -agenteye.event.human_wait( +failproofai_sdk.event.human_wait( session_id="run-001", agent_id="planner", input_id="inp-abc", # str, required - correlation key for the matching human_input @@ -357,7 +373,7 @@ agenteye.event.human_wait( Emitted when a human provides input and the agent resumes. Correlates with `human_wait` via `input_id`. `duration_ms` is auto-computed and must not be passed by the caller. ```python -agenteye.event.human_input( +failproofai_sdk.event.human_input( session_id="run-001", agent_id="planner", input_id="inp-abc", # str, required - must match the prior human_wait @@ -371,7 +387,7 @@ agenteye.event.human_input( Emitted when a human actively pauses the agent (e.g. via a dashboard control). The agent is suspended but not terminated. ```python -agenteye.event.human_pause( +failproofai_sdk.event.human_pause( session_id="run-001", agent_id="planner", reason="user_requested", # str | None @@ -384,7 +400,7 @@ agenteye.event.human_pause( Emitted when a human actively stops the agent mid-execution. Unlike `human_pause`, the agent's work is terminated rather than suspended. ```python -agenteye.event.human_interrupt( +failproofai_sdk.event.human_interrupt( session_id="run-001", agent_id="planner", reason="output_incorrect", # str | None @@ -400,7 +416,7 @@ agenteye.event.human_interrupt( Any extra keyword arguments are appended to the event after the standard fields: ```python -agenteye.event.tool_use( +failproofai_sdk.event.tool_use( session_id="run-001", agent_id="planner", tool_name="db_query", @@ -421,7 +437,7 @@ Keep payloads as structured JSON when you want to query their fields. Values JSO Events are buffered in-process and flushed to disk every `flush_interval` seconds (default 500 ms). Each flush writes one JSONL file: ```text -~/.agenteye/events/event-2026-04-01T12-00-00-000Z.jsonl +~/.agenteye/events/event-2026-04-01T12-00-00-000Z-48213-7.jsonl ``` The collector watches this directory and uploads files automatically. You do not need to manage these files directly. diff --git a/docs/docs.json b/docs/docs.json index 9c61d2eb5..e5fe7b46e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -80,7 +80,20 @@ "start/first-audit", "start/first-policy", "start/setup", - "start/concepts" + "start/concepts", + "start/integrations", + { + "group": "Plug in your agents", + "expanded": false, + "pages": [ + "start/integrations/how-it-works", + "start/integrations/custom-agents", + "start/integrations/langchain", + "start/integrations/crewai", + "start/integrations/llamaindex", + "start/integrations/pydantic-ai" + ] + } ] } ] diff --git a/docs/reference/overview.mdx b/docs/reference/overview.mdx index 7488d280c..8dc02cd88 100644 --- a/docs/reference/overview.mdx +++ b/docs/reference/overview.mdx @@ -10,8 +10,11 @@ Choose the integration closest to where your agent already runs. Install hooks for supported coding and autonomous agent CLIs. - - Instrument traces to find failures in custom agents, then contact us to add prevention to your runtime. + + Instrument LangGraph, CrewAI, LlamaIndex, Pydantic AI, or a custom agent. + + + Configuration, the event catalog, correlation rules, and delivery. Review local projects, sessions, policy activity, and offline audits. diff --git a/docs/reference/python-sdk.mdx b/docs/reference/python-sdk.mdx index 73cd0ffbf..01ed72e8d 100644 --- a/docs/reference/python-sdk.mdx +++ b/docs/reference/python-sdk.mdx @@ -1,34 +1,35 @@ --- -title: "Custom agents" -description: "Instrument traces from custom agents so Failproof AI can reconstruct runs and find failures." +title: "Python SDK reference" +description: "Configuration, the event catalog, correlation rules, and delivery for failproofai-sdk." icon: "python" --- -Instrument traces from a custom agent with `failproofai-sdk` so Failproof AI can reconstruct each run, audit its behavior, and find evidence-backed failures. The SDK writes structured events for the Failproof daemon to deliver to Cloud. It requires Python 3.10 or newer. +Reference material for `failproofai-sdk`. To connect an agent for the first time, start with the integration guides instead. -Tracing makes custom agents observable and auditable. Preventing an unsafe action before it executes also requires an enforcement hook in your runtime. + + + LangGraph, CrewAI, LlamaIndex, Pydantic AI, and custom agents. + + + Scopes, event methods, threads, and instrumenting a framework without an adapter. + + + +The SDK writes structured events for the Failproof daemon to deliver to Cloud. It requires Python 3.10 or newer and has no runtime dependencies. + +Tracing makes agents observable and auditable. Preventing an unsafe action before it executes also requires an enforcement hook in your runtime. To enforce policies in a custom agent setup, [contact Failproof AI](mailto:support@befailproof.ai). We will help map your runtime's model, tool, and lifecycle boundaries to policy hooks. -
- -
- -## Install `failproofai-sdk` - -The SDK is currently distributed as a private wheel. Ask your Failproof AI contact for the current version and download access. +## Install ```bash -VERSION= -pip install "./failproofai_sdk-${VERSION}-py3-none-any.whl" -python -c "import failproofai; print(failproofai.__version__)" +pip install failproofai-sdk ``` -With `uv`, download the wheel first and run `uv add ./failproofai_sdk-${VERSION}-py3-none-any.whl`. Pin the wheel in a private artifact repository or dependency lock. - -The package is installed as `failproofai-sdk` and imported in Python as `failproofai`. +The package is installed as `failproofai-sdk` and imported in Python as `failproofai_sdk`. Framework extras such as `failproofai-sdk[langgraph]` install the framework itself; the adapters always ship in the base wheel. ## Connect the Failproof daemon @@ -51,73 +52,12 @@ The package is installed as `failproofai-sdk` and imported in Python as `failpro -## Instrument a complete run - -Call `configure()` once during process startup. Every event call is keyword-only and requires a stable `session_id` and `agent_id`. +## Configuration ```python -import traceback -import uuid - -import failproofai - -failproofai.configure(environment="production") +import failproofai_sdk -session_id = uuid.uuid4().hex -agent_id = "checkout-agent" - -failproofai.event.agent_start( - session_id=session_id, - agent_id=agent_id, - goal="Resolve a failed checkout", -) - -try: - tool_call_id = uuid.uuid4().hex - failproofai.event.tool_use( - session_id=session_id, - agent_id=agent_id, - tool_name="lookup_order", - tool_call_id=tool_call_id, - input={"order_id": "ord_8421"}, - ) - result = {"status": "payment_failed"} - failproofai.event.tool_result( - session_id=session_id, - agent_id=agent_id, - tool_name="lookup_order", - tool_call_id=tool_call_id, - output=result, - ) -except Exception as exc: - failproofai.event.error( - session_id=session_id, - agent_id=agent_id, - error_type=type(exc).__name__, - message=str(exc), - traceback=traceback.format_exc(), - ) - failproofai.event.agent_end( - session_id=session_id, - agent_id=agent_id, - outcome="failed", - ) - raise -else: - failproofai.event.agent_end( - session_id=session_id, - agent_id=agent_id, - outcome="success", - summary="Escalated the failed payment", - ) -``` - -Emit `agent_start` once per actor. For sub-agents, reuse the parent's `session_id`, give each actor a distinct `agent_id`, and set `parent_id` to the parent **agent ID**, not the session ID. - -## Configuration reference - -```python -failproofai.configure( +failproofai_sdk.configure( base_dir=None, flush_interval=0.5, environment="production", @@ -128,25 +68,58 @@ failproofai.configure( | --- | --- | | `base_dir` | Explicit spool root. Takes precedence over all environment variables. | | `flush_interval` | Seconds between background writes from memory to JSONL. Default: `0.5`. | -| `environment` | Deployment label on every event. Defaults to `dev`. | +| `environment` | Deployment label on every event. Defaults to `dev`. Must not contain a comma — ingest splits this field on commas to build its filter facets and skips any event whose label has one, so `configure()` raises rather than let a run vanish. Use `prod-eu`, not `prod,eu`. | | `FAILPROOFAI_HOME` | Changes the Failproof AI root that contains the `custom-agents` spool. | +| `FAILPROOFAI_SDK_STRICT` | Set to `1` to re-raise instrumentation errors instead of logging them. | -The SDK writes to the explicit `base_dir` when set. Otherwise, it uses the Failproof daemon's `custom-agents` spool under `FAILPROOFAI_HOME` or `~/.failproofai`. +The SDK writes to the explicit `base_dir` when set. Otherwise it uses the Failproof daemon's `custom-agents` spool under `FAILPROOFAI_HOME` or `~/.failproofai`. -The SDK queues calls in memory and writes batches on a background thread. It also attempts a final flush through Python's `atexit` handling. For short-lived workers, allow normal interpreter shutdown; hard process termination can lose events still in memory. +The SDK queues calls in memory and writes batches on a background thread, with a final flush through Python's `atexit` handling. For short-lived workers, allow normal interpreter shutdown; hard process termination can lose events still in memory. + + + `SIGTERM` is one of those hard terminations, and it is the one you will actually meet: every rolling deploy, every `docker stop`, every Kubernetes eviction. CPython installs no handler for it, so the process ends where it stands and the `atexit` flush never runs. What is in flight at shutdown is disproportionately `agent_end`, so runs stay open and never close. + + If your process can receive `SIGTERM`, handle it. The SDK will not install a handler in your process on your behalf. + + ```python + import signal, sys, failproofai_sdk + + def _flush_and_exit(signum, frame): + failproofai_sdk._writer.flush_now() + sys.exit(128 + signum) + + signal.signal(signal.SIGTERM, _flush_and_exit) + ``` + + `sys.exit` rather than `os._exit`: it unwinds, so an open `agent()` scope still emits its `agent_end` before the flush. That scope closes `outcome="failed"` — an evicted run did not finish, which is the thing worth being able to see. `SIGKILL` and a container OOM cannot be handled by anything. + + +## Identity + +`session_id` and `agent_id` are optional on every event method. Omitted, they resolve from the enclosing scope: + +```python +with failproofai_sdk.session(): + with failproofai_sdk.agent("planner"): + failproofai_sdk.event.tool_use(tool_name="search", tool_call_id="c1") +``` + +Passing them explicitly still works and takes precedence. With nothing bound and nothing passed, the call raises a `TypeError` naming the fix rather than emitting an event with no session, which ingest would skip while answering `200`. + +Scopes bind identity on context variables. Those propagate into asyncio tasks automatically but not into new threads — wrap a worker in `failproofai_sdk.propagate()`. ## Event catalog All methods return `None`. Fields left as `None` are omitted rather than written as JSON `null`. -| Method | Required fields beyond identity | Optional fields | +| Method | Required fields | Optional fields | | --- | --- | --- | | `agent_start` | — | `goal`, `parent_id` | | `agent_end` | — | `outcome`, `summary` | | `agent_pause` | `pause_id` | `reason`, `user_id` | | `agent_resume` | `pause_id` | `reason`, `user_id` | -| `model_request` | — | `model`, `messages`, `system`, `tools` | -| `model_response` | — | `model`, `stop_reason`, `input_tokens`, `output_tokens`, `content`, `role` | +| `model_request` | — | `model`, `messages`, `system`, `tools`, `request_id` | +| `model_response` | — | `model`, `stop_reason`, `input_tokens`, `output_tokens`, `content`, `role`, `request_id`, `duration_ms` | | `tool_use` | `tool_name`, `tool_call_id` | `input` | | `tool_result` | `tool_name`, `tool_call_id` | `output`, `error` | | `hook_triggered` | `hook_name`, `hook_id` | `trigger_event`, `input` | @@ -159,11 +132,13 @@ All methods return `None`. Fields left as `None` are omitted rather than written Use `outcome="failed"`, `"error"`, `"timeout"`, or `"rejected"` when a completion should count as a failure. Other values, including `"failure"`, are not classified as failures by the current backend. -## Correlation and duration rules +## Correlation and duration - Reuse the same `tool_call_id`, `hook_id`, `pause_id`, or `input_id` for the matching completion event. -- The SDK computes `duration_ms` for `tool_result`, `hook_completed`, `agent_resume`, and `human_input`. Passing it yourself to those methods raises `ValueError`. -- Tool and hook IDs share one process-wide pending map. Make them globally unique across concurrent sessions and across both namespaces; provider IDs or UUIDs are safest. +- The SDK computes `duration_ms` for `tool_result`, `hook_completed`, `agent_resume`, and `human_input`. Passing it to those methods raises `ValueError`. +- `duration_ms` **is** accepted on `model_response`, because only the caller knows the real provider latency. It must be an integer; a float is stored as null. +- Correlation keys are scoped by kind and session, so a tool call and a hook may safely share an id, and two concurrent sessions may reuse the same ids without colliding. They are not scoped by agent: a pair opened under one agent and closed under another still correlates, which is the ordinary case in multi-agent frameworks. +- `request_id` pairs `model_request` with `model_response`. Without it, model events pair in order per agent, so concurrent calls mispair. - A pair split across processes still correlates downstream, but the SDK cannot compute its in-process duration. - The pending map holds at most 10,000 starts and evicts the oldest entry when full. @@ -171,7 +146,11 @@ Use `outcome="failed"`, `"error"`, `"timeout"`, or `"rejected"` when a completio Every event accepts extra keyword fields. Use JSON-compatible values when downstream queries need structure. Unsupported leaves such as UUIDs, datetimes, decimals, sets, bytes, and model objects are stringified by the writer. -Reserved custom names are `timestamp`, `session_id`, `agent_id`, `type`, and `environment`. Optional-field typos are accepted as new custom fields, so review emitted JSON when a standard field does not appear in Cloud. +Reserved names are `timestamp`, `session_id`, `agent_id`, `type`, and `environment`. + + + Extra fields are merged last, so one named like a declared field, such as `model`, `tool_name`, or `outcome`, would overwrite it and change a stored column. Namespace your own fields; the framework adapters use an `fw_` prefix. Optional-field typos are accepted as new custom fields, so review emitted JSON when a standard field does not appear in Cloud. + ## Deliver and verify @@ -191,6 +170,10 @@ Reserved custom names are `timestamp`, `session_id`, `agent_id`, `type`, and `en If Cloud is empty, inspect `$FAILPROOFAI_HOME/custom-agents/events`, otherwise `~/.failproofai/custom-agents/events`. JSONL files prove SDK emission; a growing spool points to daemon configuration or delivery, while an empty spool points to instrumentation or process lifetime. + + Inspect the spool only when the daemon is stopped. While it runs, it collects and deletes each batch within milliseconds, so a directory listing races the collector and shows far fewer events than were emitted. + + ## Prevent failures in a custom runtime Use audit findings and linked traces to define the unsafe action, required evidence, and intended response. A custom enforcement integration must expose the action before execution, pass its structured input to the policy engine, and apply the resulting allow, instruct, or deny decision. diff --git a/docs/start/integrations.mdx b/docs/start/integrations.mdx new file mode 100644 index 000000000..191874adf --- /dev/null +++ b/docs/start/integrations.mdx @@ -0,0 +1,252 @@ +--- +title: "Instrument your agent" +sidebarTitle: "Frameworks" +description: "Connect any supported agent framework to Failproof AI with one call." +icon: "plug" +--- + +Your agent already produces everything worth recording — model calls, tool calls, node boundaries, human waits, failures. The framework throws it away. The SDK keeps it. + + + + Graphs, nodes, tools, retrievers, models, `interrupt()` pauses. + + + Crews, flows, agents by role, tools, memory, human feedback. + + + Workflows, steps, function agents, retrievers. + + + Typed agents, capabilities, tools, retries. + + + No framework, or one not listed here. + + + The data model, the ids, and how events reach Cloud. + + + +## Three lines, whichever framework + + +```bash LangGraph +pip install 'failproofai-sdk[langgraph]' +``` + +```bash CrewAI +pip install 'failproofai-sdk[crewai]' +``` + +```bash LlamaIndex +pip install 'failproofai-sdk[llamaindex]' +``` + +```bash Pydantic AI +pip install 'failproofai-sdk[pydantic-ai]' +``` + +```bash No framework +pip install failproofai-sdk +``` + + +```python +import failproofai_sdk + +failproofai_sdk.configure(environment="production") +failproofai_sdk.instrument() # detects the frameworks you imported + +with failproofai_sdk.session(): + ... # your existing agent call, unchanged +``` + +No decorators on your functions, no callback passed to your calls, no ids threaded through your code. Only the call inside the session differs: + + + + ```python + graph.invoke({"messages": [HumanMessage("...")]}) + ``` + + + ```python + Crew(agents=[analyst, writer], tasks=[gather, summarise]).kickoff() + ``` + + + ```python + await agent.run("...") # under `async with failproofai_sdk.session():` + ``` + + + ```python + agent.run_sync("...") + ``` + + + ```python + with failproofai_sdk.agent("planner"): + with failproofai_sdk.tool_call("search", input={"q": q}) as t: + t.output = search(q) + ``` + + + +The extra installs the **framework**. Every adapter ships in the base wheel, so a project that already has its framework needs no extra at all. + +## What you get back + +Every recording has the same shape: a span opens, work nests inside it, and each opening event gets a closing one. + +```mermaid +flowchart LR + S(["agent_start"]) --> H["hook_triggered"] + H --> M["model_request
model_response"] + H --> T["tool_use
tool_result"] + M --> C["hook_completed"] + T --> C + C --> E(["agent_end"]) +``` + +The **pair** is the unit. Each closing event carries a duration the SDK measures from its opening one. + +Below is one real run per framework — captured from the examples that ship with the SDK, model name normalised. Note how much comes back from a single call. + + + + ```text 14 events + 1 +0.000s agent_start LangGraph + 2 +0.001s hook_triggered agent + 3 +0.002s model_request gpt-4o-mini + 4 +3.023s model_response gpt-4o-mini · 21 out-tok + 5 +3.024s hook_completed agent + 6 +3.024s hook_triggered tools + 7 +3.025s tool_use word_count + 8 +3.025s tool_result word_count · ok + 9 +3.025s hook_completed tools + 10 +3.026s hook_triggered agent + 11 +3.027s model_request gpt-4o-mini + 12 +5.717s model_response gpt-4o-mini · 5 out-tok + 13 +5.720s hook_completed agent + 14 +5.721s agent_end LangGraph · success + ``` + + Nodes become hook pairs, so you get per-node latency without them crowding the agent list. + + + + ```text 10 events + 1 +0.000s agent_start crew + 2 +0.050s agent_start analyst · under crew + 3 +0.057s model_request gpt-4o-mini + 4 +3.475s model_response gpt-4o-mini · 19 out-tok + 5 +3.478s tool_use lookup_metric + 6 +3.478s tool_result lookup_metric · ok + 7 +3.486s model_request gpt-4o-mini + 8 +5.694s model_response gpt-4o-mini · 9 out-tok + 9 +5.727s agent_end analyst · success + 10 +5.739s agent_end crew · success + ``` + + Each agent's `role` becomes its span name, so latency and token spend break down per role. + + + + ```text 26 events + 1 +0.000s agent_start Agent + 2 +0.001s hook_triggered init_run + 4 +0.501s hook_triggered setup_agent + 6 +0.503s hook_triggered run_agent_step + 7 +0.505s model_request gpt-4o-mini + 8 +3.083s model_response gpt-4o-mini · 18 out-tok + 10 +3.197s hook_triggered parse_agent_output + 12 +3.355s hook_triggered call_tool + 13 +3.355s tool_use city_population + 14 +3.355s tool_result city_population · ok + 16 +3.356s hook_triggered aggregate_tool_results + ... second iteration + 26 +7.038s agent_end Agent · success + ``` + + The agent loop itself is visible, not only its model calls. + + + + ```text 8 events + 1 +0.000s agent_start agent + 2 +0.001s model_request gpt-4o-mini + 3 +4.413s model_response gpt-4o-mini · 17 out-tok + 4 +4.415s tool_use population + 5 +4.415s tool_result population · ok + 6 +4.416s model_request gpt-4o-mini + 7 +8.118s model_response gpt-4o-mini · 6 out-tok + 8 +8.119s agent_end agent · success + ``` + + No hook pairs: Pydantic AI has no node or step boundary to bracket. + + + + ```text 6 events + 1 +0.000s agent_start main + 2 +0.000s tool_use population + 3 +0.000s tool_result population · ok + 4 +0.000s model_request gpt-4o-mini + 5 +0.000s model_response gpt-4o-mini · 3 out-tok + 6 +0.000s agent_end main · success + ``` + + You emit these yourself. Same event types, same fidelity — it costs you the call sites. + + + +## The event types + +| Group | Events | +| --- | --- | +| Agents | `agent_start`, `agent_end`, `agent_pause`, `agent_resume` | +| Models | `model_request`, `model_response` | +| Tools | `tool_use`, `tool_result` | +| Hooks | `hook_triggered`, `hook_completed` | +| Humans | `human_wait`, `human_input`, `human_pause`, `human_interrupt` | +| Failures | `error` | + +Which framework records what, measured from the runs above: + +| Event | LangGraph | CrewAI | LlamaIndex | Pydantic AI | Custom | +| --- | :--: | :--: | :--: | :--: | :--: | +| Agent start and end | Yes | Yes | Yes | Yes | You | +| Model request and response | Yes | Yes | Yes | Yes | You | +| Tool use and result | Yes | Yes | Yes | Yes | You | +| Hook triggered and completed | Node | Task | Step | — | You | +| Error | Yes | Yes | Yes | Yes | Automatic | +| Human wait and input | Yes | Yes | Yes | — | You | +| Agent pause and resume | Yes | Yes | Yes | — | You | + +A dash means the framework has no such concept. `human_pause` and `human_interrupt` describe a *person* acting on the agent, which no framework signals — emit those yourself. + +## Check it arrived + +Run one instrumented session, then open **Observe → Sessions** and select your environment. The run appears as a reconstructed trace. + +If nothing arrives, confirm the machine is connected with `failproofai config --status`. See [Set up capture](/start/setup). + + + Do not check the spool directory to confirm delivery. The Failproof daemon collects and deletes each batch within milliseconds, so reading it races the collector and shows far fewer events than were emitted. + + +## Next + + + + Pairs, ids, session lifecycle, and delivery. + + + Follow causality through a session instead of disconnected logs. + + + Audit the sessions you just captured. + + diff --git a/docs/start/integrations/crewai.mdx b/docs/start/integrations/crewai.mdx new file mode 100644 index 000000000..8c1d2a76f --- /dev/null +++ b/docs/start/integrations/crewai.mdx @@ -0,0 +1,224 @@ +--- +title: "CrewAI" +sidebarTitle: "CrewAI" +description: "Instrument crews, flows, agents by role, tools, memory, and human feedback." +icon: "users" +--- + +## Install + +```bash +pip install 'failproofai-sdk[crewai]' +``` + +Supported: `crewai` 1.13 to 2.0. 1.13 is the release that added `started_event_id` and normalized token usage, both of which the adapter relies on to pair events and report tokens. + +## Instrument + +```python +import failproofai_sdk + +failproofai_sdk.configure(environment="production") +failproofai_sdk.instrument() + +with failproofai_sdk.session(): + Crew(agents=[analyst, writer], tasks=[gather, summarise]).kickoff() +``` + +`instrument()` registers a listener on CrewAI's module-level event bus and subscribes one handler per event class. Nothing about your crew, agents, tasks, or tools changes. + +## What gets recorded + +| CrewAI | Failproof event | +| --- | --- | +| Crew kickoff | `agent_start`, `agent_end` | +| `Agent.kickoff()` (a lite agent, no crew) | `agent_start`, `agent_end`, with `agent_id` from the role | +| Flow start and finish | `agent_start`, `agent_end`; a crew kicked off inside a flow method nests under it | +| Agent execution | Nested `agent_start`, `agent_end`, with `agent_id` from the role. Under a hierarchical process a delegated coworker nests under the manager, not beside it | +| Task | Nothing; recorded as a link so children resolve to the crew | +| Flow method, guardrail | `hook_triggered`, `hook_completed` | +| Tool usage | `tool_use`, `tool_result` | +| Memory and knowledge operations | `tool_use`, `tool_result`, named for the surface hit | +| LLM call | `model_request`, `model_response`, with token usage | +| Stream chunk | Folded into the response as chunk count and time to first token | +| Human feedback requested | `human_wait`, `agent_pause` | +| Human feedback received | `agent_resume`, `human_input` | +| Agent execution error | `error`, then `agent_end` with outcome `failed` | + +A task emits nothing on purpose. A CrewAI task is a subset of the agent execution that runs it, so emitting both would double every row and render them as siblings. The task id and name ride along on the agent's own events instead. + +Memory and knowledge operations are recorded as tools, named for the surface they hit, so they appear next to your real tools where you can compare their latency. + +On a hierarchical crew, the nesting is what makes the trace readable: + +```text +crew +└─ manager + ├─ researcher delegated + └─ writer delegated +``` + +CrewAI parents a delegated execution on the `delegate_work_to_coworker` **tool event**, not on the manager directly, so the adapter follows that link. Without it every agent comes out a sibling of every other and the delegation structure is lost. + +## Example + +```python +import failproofai_sdk +from crewai import Agent, Crew, Process, Task +from crewai.tools import tool + +failproofai_sdk.configure(environment="production") +failproofai_sdk.instrument() + +MODEL = "openai/gpt-4o-mini" +METRICS = {"revenue": "$4.2M ARR, up 12% QoQ", "churn": "3.1% monthly, up from 2.4%"} + + +@tool("lookup_metric") +def lookup_metric(name: str) -> str: + """Look up a business metric by name. Valid: revenue, churn.""" + return METRICS.get(name.lower().strip(), "unknown metric") + + +analyst = Agent( + role="analyst", # becomes agent_id + goal="pull the numbers that matter and state them plainly", + backstory="You read dashboards for a living.", + tools=[lookup_metric], + llm=MODEL, +) +writer = Agent( + role="writer", + goal="turn numbers into three lines an exec will read", + backstory="You write board updates. You never pad.", + llm=MODEL, +) + +gather = Task( + description="Look up 'revenue' and 'churn' with the tool.", + expected_output="Two lines, one metric each.", + agent=analyst, +) +summarise = Task( + description="Using the metrics above, write a three-line exec summary.", + expected_output="Exactly three lines.", + agent=writer, + context=[gather], +) + +with failproofai_sdk.session(): + result = Crew( + agents=[analyst, writer], + tasks=[gather, summarise], + process=Process.sequential, + ).kickoff() +``` + +The handoff is visible in the trace: the `analyst` span closes, the `writer` span opens, and both sit inside one `crew` span. + +## Name your spans + +`agent_id` comes from `Agent(role=...)`, which is what makes it a readable dashboard facet. + +```python +Agent(role="analyst", ...) # agent_id = "analyst" +Agent(role="analyst-7f3a2b", ...) # one facet entry per run +``` + +`agent_id` is a low-cardinality column. A role containing a run id or timestamp degrades it for every query anyone runs. If a role looks like an id, the adapter refuses it and puts the real value in a payload field instead. + +## Control the session + +Resolved in this order, first match winning: + +1. `instrument("crewai", session_id=...)` +2. The enclosing `failproofai_sdk.session()` scope +3. A generated `uuid4().hex`, once per crew or flow + +Wrap the kickoff to control it per run: + +```python +with failproofai_sdk.session(f"support-{ticket_id}"): + Crew(agents=[...], tasks=[...]).kickoff() +``` + +## Options + +```python +failproofai_sdk.instrument( + "crewai", + session_id=None, # pin every run to one session id +) +``` + +`session_id` is the only option this adapter reads. Prompts and completions are always recorded, truncated to the payload budget. + +## Human in the loop + +CrewAI has **two** human-in-the-loop surfaces, and both are recorded as the same four events. + +`@human_feedback` on a flow method goes through CrewAI's event bus: the runtime emits an event before it blocks on a person and another after the answer. + +`Task(human_input=True)` does not. It calls `input()` inside CrewAI's own input provider and emits no event of any kind, so the adapter wraps that provider directly — without it the entire human wait was invisible and billed as active agent time. + +Either way you get: + +```text +human_wait the prompt and its options +agent_pause starts the paused-time clock +agent_resume stops it +human_input the answer, with the wait measured +``` + +`agent_pause` to `agent_resume` is the only pair that feeds paused time. Without it, a ten-minute human wait is billed as ten minutes of active agent time. + + + CrewAI sets no correlation id on either human-feedback event, so the adapter pairs them on the flow and method name, falling back to the most recently opened pause. That is sound because a console prompt blocks. If you build a concurrent feedback provider, set `request_id` on both events. + + + + Because the `Task(human_input=True)` path is a wrapper around CrewAI's input provider rather than an event subscription, it is restored on `uninstrument()` and re-raises whatever `input()` raises, `KeyboardInterrupt` included, unchanged. + + +## Common problems + + + + A `role` contains a UUID, timestamp, or per-run suffix. Use a stable human role and put the run-specific id in the task description instead. + + + + The event bus is asynchronous, and `kickoff()` returns before the last handlers run. Drain it first: + + ```python + from crewai.events.event_bus import crewai_event_bus + + crew.kickoff() + crewai_event_bus.flush(timeout=30) + ``` + + This is a property of CrewAI, not of the SDK. + + + + `agent_end` force-closes open pauses but not tools or models, so a run that dies inside a tool call leaves that span open. Normal teardown closes whatever is still open and marks it incomplete. Only a `SIGKILL` leaves it hanging, because nothing can run. + + + + Check in this order: `instrument()` ran before `kickoff()`; there is a `with failproofai_sdk.session():` around it; `crewai` is 1.13 or newer; `FAILPROOFAI_SDK_STRICT=1` set, so a degraded hook raises instead of being swallowed. + + + +## Next + + + + Pairs, ids, session lifecycle, and delivery. + + + Follow causality through the session you just captured. + + + LangGraph, LlamaIndex, Pydantic AI, and custom agents. + + diff --git a/docs/start/integrations/custom-agents.mdx b/docs/start/integrations/custom-agents.mdx new file mode 100644 index 000000000..0b3ef59f3 --- /dev/null +++ b/docs/start/integrations/custom-agents.mdx @@ -0,0 +1,311 @@ +--- +title: "Custom agents" +sidebarTitle: "Custom agents" +description: "Instrument an agent you wrote yourself, or a framework without an adapter." +icon: "wrench" +--- + +For an agent you wrote yourself, or a framework Failproof AI has no adapter for. There is nothing to instrument: you emit the events. + +This is the same API the four framework adapters call underneath. They are translation tables over it. + +## Install + +```bash +pip install failproofai-sdk +``` + +No extras, and no dependencies. + +## The three scopes + +```python +import failproofai_sdk + +failproofai_sdk.configure(environment="production") + +with failproofai_sdk.session(): # identity only, emits nothing + with failproofai_sdk.agent("planner"): # agent_start, agent_end + with failproofai_sdk.tool_call("search", input={"q": q}) as t: + t.output = search(q) # tool_use, tool_result +``` + +| Scope | Emits | Purpose | +| --- | --- | --- | +| `session()` | Nothing | Binds a session id, grouping one run | +| `agent()` | `agent_start`, `agent_end` | Brackets a unit of work | +| `tool_call()` | `tool_use`, `tool_result` | Brackets one tool and measures it | + +Everything inside can omit `session_id` and `agent_id`. The scopes bind identity on context variables and every event call reads it back, so you never thread ids through your functions. + +All three work under `async with` as well as `with`. + +Nesting agents builds the tree. `parent_id` and depth are computed from the stack: + +```python +with failproofai_sdk.session(): + with failproofai_sdk.agent("supervisor"): + with failproofai_sdk.agent("researcher"): # parent_id = "supervisor" + ... +``` + +## How a scope closes + +`agent()` handles exceptions for you: + +| What happened | Events | Outcome | +| --- | --- | --- | +| Nothing raised | `agent_end` | `success` | +| `Exception` | `error`, then `agent_end` | `failed` | +| `KeyboardInterrupt`, `SystemExit` | `error`, then `agent_end` | `failed` | +| `CancelledError`, `GeneratorExit` | `agent_end` only | `cancelled` | + +The error is emitted before `agent_end`, because the dashboard closes the span at `agent_end` and anything after it is attributed to nothing. A cancellation is not a failure, so cancelled runs do not pollute the errors surface. The exception is always re-raised: a scope never swallows. + +## The event methods + +Fifteen methods in six families. Most come in pairs — you emit the opener, then the closer, and the SDK measures the span between them. + +| Family | Opens | Closes | Standalone | +| --- | --- | --- | --- | +| **Agents** | `agent_start` | `agent_end` | — | +| | `agent_pause` | `agent_resume` | — | +| **Models** | `model_request` | `model_response` | — | +| **Tools** | `tool_use` | `tool_result` | — | +| **Hooks** | `hook_triggered` | `hook_completed` | — | +| **Humans** | `human_wait` | `human_input` | `human_pause`, `human_interrupt` | +| **Failures** | — | — | `error` | + + + Prefer the scopes — `agent()` and `tool_call()` — wherever they fit. They guarantee the closing event even when the body raises. Reach for these methods directly when your control flow doesn't nest, such as a model call inside a helper. + + + +```python Agents +failproofai_sdk.event.agent_start(agent_id="planner", goal="find the cheapest flight") +failproofai_sdk.event.agent_end(agent_id="planner", outcome="success", summary="...") +failproofai_sdk.event.agent_pause(pause_id="p1", reason="awaiting approval") +failproofai_sdk.event.agent_resume(pause_id="p1") +``` + +```python Models +failproofai_sdk.event.model_request( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "..."}], + request_id="req-1", +) +failproofai_sdk.event.model_response( + model="gpt-4o-mini", + content="...", + input_tokens=139, + output_tokens=21, + request_id="req-1", + duration_ms=5202, +) +``` + +```python Tools +failproofai_sdk.event.tool_use(tool_name="search", tool_call_id="c1", input={"q": "..."}) +failproofai_sdk.event.tool_result(tool_name="search", tool_call_id="c1", output="...") +``` + +```python Hooks +failproofai_sdk.event.hook_triggered(hook_name="retrieve", hook_id="h1", trigger_event="node") +failproofai_sdk.event.hook_completed(hook_name="retrieve", hook_id="h1", outcome="success") +``` + +```python Humans +failproofai_sdk.event.human_wait(input_id="i1", prompt="Approve?", options=["yes", "no"]) +failproofai_sdk.event.human_input(input_id="i1", response="yes") +failproofai_sdk.event.human_pause(reason="operator paused the run", user_id="dana") +failproofai_sdk.event.human_interrupt(reason="operator stopped the run", at_step="step_3") +``` + +```python Failures +failproofai_sdk.event.error( + error_type="TimeoutError", + message="provider timed out after 30s", + traceback="...", +) +``` + + + + **The two human families point in opposite directions.** + + | Methods | Meaning | + | --- | --- | + | `human_wait` / `human_input` | The **agent asked a person** — an approval gate, a clarifying question | + | `human_pause` / `human_interrupt` | A **person acted on the agent** — a stop button, an operator pause | + + No framework signals the second pair, so it is always yours to emit. + + + + **Pass `request_id` when model calls run concurrently.** Without it, requests and responses pair in arrival order per agent — and concurrent calls mispair, attaching each response to the wrong request. + + +## Example + +A tool-calling loop against the OpenAI API, with no agent framework: + +```python +import json + +import failproofai_sdk +from openai import OpenAI + +failproofai_sdk.configure(environment="production") +client = OpenAI() +MODEL = "gpt-4o-mini" + + +def turn(messages: list): + """One model call, bracketed by the pair.""" + failproofai_sdk.event.model_request(model=MODEL, messages=messages) + reply = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOLS) + usage = reply.usage + failproofai_sdk.event.model_response( + model=MODEL, + content=reply.choices[0].message.content or "", + input_tokens=usage.prompt_tokens, + output_tokens=usage.completion_tokens, + ) + return reply.choices[0].message + + +with failproofai_sdk.session(): + with failproofai_sdk.agent("inventory", goal="price report"): + for _ in range(4): # bounded; an unbounded agent loop is its own bug + message = turn(messages) + if not message.tool_calls: + break + messages.append(message.model_dump(exclude_none=True)) + for call in message.tool_calls: + args = json.loads(call.function.arguments or "{}") + with failproofai_sdk.tool_call( + call.function.name, tool_call_id=call.id, input=args + ) as handle: + handle.output = run_tool(call.function.name, args) + messages.append({ + "role": "tool", + "tool_call_id": call.id, + "content": str(handle.output), + }) +``` + +That produces the same six event types an adapter would give you. The complete +runnable version, with the tool definitions, ships in the SDK repository under +`docs/manual/examples/`. + +## Threads and async + +Context variables propagate into asyncio tasks automatically. They do not propagate into new threads, because a thread starts with an empty context. + +```python +# asyncio: nothing to do +async with failproofai_sdk.session(): + await asyncio.gather(worker(1), worker(2)) + +# threads: wrap the callable +pool.submit(failproofai_sdk.propagate(work), x) +threading.Thread(target=failproofai_sdk.propagate(work)).start() +loop.run_in_executor(None, failproofai_sdk.propagate(work), x) +``` + +Without `propagate()`, the worker's events raise a `TypeError` naming the fix rather than landing on no session. That is deliberate: an event with no session is skipped by ingest and answered `200`, which is the silent failure the identity layer exists to prevent. + +## Instrument a framework without an adapter + +Every agent framework gives you the same three seams. Map them and you have a complete trace — the four shipped adapters do nothing more than this. + +| The seam | What you write | What lands | +| --- | --- | --- | +| The run | `session()` + `agent()` | `agent_start`, `agent_end` | +| Each tool | `tool_call()` | `tool_use`, `tool_result` | +| Each model call | The `model_*` pair | `model_request`, `model_response` | + + + + ```python + with failproofai_sdk.session(): + with failproofai_sdk.agent(agent_name, goal=task): + result = framework.run(task) + ``` + + + In whatever the framework calls a tool wrapper or middleware. + + ```python + with failproofai_sdk.tool_call(name, input=args) as call: + call.output = original(**args) + ``` + + + ```python + failproofai_sdk.event.model_request(model=model, messages=messages) + reply = provider.complete(...) + failproofai_sdk.event.model_response( + model=model, + content=text, + input_tokens=usage.prompt_tokens, + output_tokens=usage.completion_tokens, + ) + ``` + + + + + **Got a node, step or middleware boundary worth seeing?** Wrap it in a hook pair — `hook_triggered` / `hook_completed` — not a nested `agent()`. `agent_id` is a low-cardinality facet, and one entry per node drowns it. Hook spans render the same way and give you per-node latency. + + + + **Manual and automatic compose.** An adapter running inside a hand-written scope joins that session and parents to that agent, so you get one tree rather than two — useful when you instrument one framework yourself alongside a supported one. + + + + Two reasons, and the three seams above are the answer to both: + + - `autogen-core` has been unmaintained since September 2025. + - AG2 exposes no process-wide registration point equivalent to the other frameworks' hooks, so instrumenting it means wrapping every agent at every construction site. + + Mapping the seams by hand records the same events, at the same fidelity, as a shipped adapter would. + + +## Common problems + + + + An opening event has no closing one: a `model_request` with no `model_response`, or a `tool_use` with no `tool_result`. Use the scopes, which guarantee the pair even when the body raises. If you call the event methods directly, use `try` and `finally`. + + + + It is measured from the matching opening event, so it is rejected on `tool_result`, `hook_completed`, `agent_resume`, and `human_input`. It is accepted on `model_response`, because only you know the real provider latency, and it must be an integer. + + + + The thread never inherited the context. Wrap the callable in `failproofai_sdk.propagate()`. See [Threads and async](#threads-and-async). + + + + Extra fields merge last, so one named like a real field such as `model` or `outcome` would overwrite it and change a stored column. Namespace yours; the adapters use an `fw_` prefix. + + + + `agent_id` is a low-cardinality facet and you put a run id in it. Use a role or node name and put the real id in a payload field. + + + +## Next + + + + Pairs, ids, session lifecycle, and delivery. + + + Follow causality through the session you just captured. + + + LangGraph, CrewAI, LlamaIndex, and Pydantic AI. + + diff --git a/docs/start/integrations/how-it-works.mdx b/docs/start/integrations/how-it-works.mdx new file mode 100644 index 000000000..c59b1d27f --- /dev/null +++ b/docs/start/integrations/how-it-works.mdx @@ -0,0 +1,255 @@ +--- +title: "How it works" +sidebarTitle: "How it works" +description: "The data model, who mints the ids, when a session ends, and how events reach Cloud." +icon: "workflow" +--- + +Read this once and the framework pages become obvious. + +## Everything is pairs + +An event never arrives alone. One opens a span, one closes it, and the closing event carries a duration the SDK measures from the opening one. + +| Opens | Closes | The closing event carries | +| --- | --- | --- | +| `agent_start` | `agent_end` | `outcome`, `summary` | +| `model_request` | `model_response` | tokens, `stop_reason`, latency | +| `tool_use` | `tool_result` | `output` or `error`, duration | +| `hook_triggered` | `hook_completed` | `outcome`, duration | +| `agent_pause` | `agent_resume` | how long the pause lasted | +| `human_wait` | `human_input` | the answer, and how long the person took | + +Because the SDK measures those durations itself, passing `duration_ms` to a closing event raises a `ValueError`. The exception is `model_response`, where only you know the real provider latency. + + + An opening event with no closing one is a span that never finishes. The session renders as still running, forever, and its active duration keeps growing. This is the failure mode to watch for when you instrument by hand. + + +## How a session ends + +**There is no session-end event.** A session is not something you close — it is a group of events sharing a `session_id`. + +Status is derived from the shape of the trace: + +| Status | When | +| --- | --- | +| `ongoing` | At least one span is still open | +| `paused` | An `agent_pause` has no matching `agent_resume` | +| `error` | Nothing is open, and at least one event failed | +| `done` | Nothing is open, and nothing failed | + +So a session ends when every pair is closed. The adapters emit `agent_end` for you, and on teardown they close anything still open and mark it incomplete — a crashed run settles as `done` with a visible gap rather than hanging. + + + This is why a session can span two calls. A LangGraph `interrupt()` pauses the run, the root span deliberately stays open, and the resuming call closes it. Both calls are one session. + + +## Who mints which id + +| Id | Minted by | Notes | +| --- | --- | --- | +| `session_id` | You, or the SDK | `session("chat-42")` is used verbatim; omitted, the SDK generates a `uuid4().hex` | +| `agent_id` | You, or the framework | From `agent("analyst")`, a CrewAI `role`, a `FunctionAgent.name`. A UUID-looking value is refused and replaced | +| `tool_call_id`, `hook_id`, `request_id` | You, or the framework | Adapters reuse the framework's own run ids, which is why pairs survive thread hops | +| **Event id** | **Cloud, at ingest** | The SDK emits none | +| **`dedup_key`** | **Cloud, at ingest** | A hash of org, session, timestamp, type and payload. This is the real identity — it makes a retried batch collapse instead of duplicating | + +### How adapters resolve `session_id` + +First match wins: + +1. An explicit `session_id` option +2. Per-call metadata +3. The enclosing `session()` scope +4. Framework metadata +5. The framework's own run id + +It is never invented while one of those exists — a synthesized id would split one run across several sessions. + +### Keep `agent_id` low cardinality + +It is the primary facet on every dashboard surface, and a `LowCardinality(String)` column. A per-run value degrades the column and fills the filter dropdown with one entry per run. + +Adapters defend that column for you: + +| The framework hands over | Recorded as | Why | +| --- | --- | --- | +| `3f9a1c2b-…` (a UUID) | `main` | Nothing readable to keep | +| A long bare hex string | `main` | Same | +| `agent-3f9a1c2b-…` | `agent` | Per-run id stripped, readable part kept | +| `agent-v2` | `agent-v2` | Short segments are left alone | +| `step-3` | `step-3` | Same | + +The real id is kept on `fw_agent_id` / `fw_run_id`, where it stays queryable without being a facet. + + + **This guard only touches labels the *framework* chose.** An `agent_id` you pass yourself — to `event.*`, or to `failproofai_sdk.agent(...)` — is recorded exactly as given. Silently rewriting an explicit argument would be worse than the cardinality it prevents, so name your own spans accordingly. + + +## How events reach Cloud + +```mermaid +flowchart LR + A["Your agent"] --> B["Adapter"] + B --> C["Writer
in-memory queue"] + C -->|"every 0.5s"| D["Spool
JSONL on disk"] + D --> E["Failproof daemon"] + E -->|"HTTPS"| F["Cloud"] +``` + +| Stage | Job | Runs in | +| --- | --- | --- | +| Adapter | Translates a framework callback into one of 15 event types | Your process | +| Writer | Queues, batches, writes JSONL atomically | Your process, background thread | +| Spool | Durable handoff, survives your process exiting | Local disk | +| Daemon | Watches the spool, ships batches, deletes what it shipped | Your machine | +| Ingest | Assigns a row id and dedup key, promotes queryable columns | Cloud | + +The spool is what makes this safe: your agent never blocks on the network, and a Cloud outage means a growing directory rather than lost events. + +Each flush writes one batch file, `.tmp` first, then `fsync`, then an atomic rename: + +```text +~/.failproofai/custom-agents/events/ + event-2026-08-20T10-15-00-123Z-48213-0.jsonl +``` + +The daemon only picks up `.jsonl`, so it can never read a half-written file. The stem carries a timestamp, process id and sequence number, so two processes flushing in the same millisecond cannot collide. The queue is capped at 10,000 events; past that it drops the oldest and logs. + + + **`collector.redact` does not apply to your SDK events.** It never sees them. + + +The daemon **ships** your batches. It does not open or rewrite them. + +| Events | Written by | Redacted by `collector.redact`? | +| --- | --- | --- | +| CLI session transcripts | The daemon | Yes | +| Hook activity | The daemon | Yes | +| **Everything the SDK emits** | **Your process** | **No** | + +Redaction runs where the daemon *writes* its own events — not where batches are *shipped*. So a prompt or a tool argument holding an API key still holds it on arrival. + +That is deliberate. These are your own instrumentation calls, and rewriting them in transit would mean the events you receive are not the events you emitted. + + + **You control payloads at the source, in two places:** + + - `capture_content=False` on the adapter — stops prompts and completions being recorded at all. + - Don't hand the secret to `input=` in the first place. + + `collector.redact` is not a substitute for either. + + + + **An empty spool directory is the healthy state.** Don't use it to check delivery. + + +The daemon deletes each batch within milliseconds of shipping it, so an `ls` races the collector and shows a fraction of what you emitted — indistinguishable from an SDK that recorded nothing. + +To confirm events actually landed, check the dashboard. To watch the spool fill up, stop the daemon first. + +## What is in the package + +Installing `failproofai-sdk` installs everything, all four adapters included. The extras pull in the **framework**, not the adapter. + +```python +import failproofai_sdk # loads nothing outside the standard library +failproofai_sdk.instrument() # imports only the adapters you actually need +``` + +`import failproofai_sdk` is contractually zero-dependency, enforced by a test that installs the built wheel with `--no-deps` and another that proves no framework reaches `sys.modules`. + + + There is no `failproofai_sdk.crewai` attribute. Adapters are deliberately not exposed on the top-level package: touching one would import the framework as a side effect of an attribute access, breaking the zero-dependency promise. Use `instrument()`. + + +```python +failproofai_sdk.instrument() # every framework already imported +failproofai_sdk.instrument("crewai") # exactly one, by name +failproofai_sdk.uninstrument("crewai") # put it back +``` + +| Name | Also accepts | +| --- | --- | +| `langchain` | `langgraph`, `langchain_core` | +| `crewai` | — | +| `llama_index` | `llamaindex`, `llama-index` | +| `pydantic_ai` | `pydantic-ai`, `pydanticai` | + +Auto-detection reads `sys.modules`, not the installed package list, so a framework you have installed but never imported is not instrumented and is never imported on your behalf. To see what is wired up: + +```python +from failproofai_sdk.integrations import active, available + +available() # ('crewai', 'langchain', 'llama_index', 'pydantic_ai') +active() # ('langchain',) +``` + + + **`instrument("crewai")` on a machine without CrewAI does not raise.** It logs a warning and returns `()`, so one missing framework never takes down a process that also instruments others. + + The warning carries the underlying `ImportError`, and that message names the exact install command — so the fix is in your logs, not hidden. + + ```text + ImportError: failproofai_sdk: cannot instrument 'crewai' because 'crewai.events' + is not importable. Install it with: pip install 'failproofai_sdk[crewai]' + ``` + + Set `FAILPROOFAI_SDK_STRICT=1` to have it raise instead. That flag is read **once and cached**, so export it before your process starts rather than setting it mid-run. + + + + **`instrument()` must come *after* your framework import.** Auto-detection reads `sys.modules`, so a bare call above the import finds nothing, installs nothing, and returns `()`. + + + +```python Wrong +import failproofai_sdk +failproofai_sdk.instrument() # sys.modules has no langchain yet -> () + +import langchain # too late, nothing is wired +``` + +```python Right +import langchain # import the framework first +import failproofai_sdk + +failproofai_sdk.instrument() # finds it -> ('langchain',) +``` + +```python Right, order-proof +import failproofai_sdk + +# Naming it imports the adapter on request, so this works from anywhere. +failproofai_sdk.instrument("langchain") +``` + + +Get this wrong and the process runs with the SDK imported, the adapter apparently installed, and **not one event emitted**. It logs a warning saying exactly that — so check your logs first when a run records nothing. + +## When instrumentation fails + +Every callback runs inside a wrapper whose only job is to re-raise, so your call sits in exactly one `try` and everything the SDK does happens outside it. + +| What happens | Result | +| --- | --- | +| A hook raises | Logged once with its traceback. Your call is unaffected | +| The same hook raises three times | That one hook is disabled for the rest of the process, with one error line | +| `FAILPROOFAI_SDK_STRICT=1` is set | The exception is re-raised instead | +| A framework version is outside the tested range | Warns once, instruments anyway | +| A single capability is missing | That one hook is disabled, never the whole adapter | + +The default is right in production and wrong while debugging, because it can only ever prove "it did not crash". Set `FAILPROOFAI_SDK_STRICT=1` to make a swallowed failure loud. + +## Next + + + + LangGraph, CrewAI, LlamaIndex, Pydantic AI, and custom agents. + + + Configuration, the event catalog, and correlation rules. + + diff --git a/docs/start/integrations/langchain.mdx b/docs/start/integrations/langchain.mdx new file mode 100644 index 000000000..1b8e30076 --- /dev/null +++ b/docs/start/integrations/langchain.mdx @@ -0,0 +1,244 @@ +--- +title: "LangChain and LangGraph" +sidebarTitle: "LangChain and LangGraph" +description: "Instrument graphs, nodes, tools, retrievers, and model calls with one call." +icon: "share-2" +--- + +One adapter serves both. LangGraph runs on `langchain-core`'s callback manager, so instrumenting one instruments the other. + +## Install + +```bash +pip install 'failproofai-sdk[langgraph]' +``` + +For LangChain without LangGraph, use `failproofai-sdk[langchain]`. + +Supported: `langchain-core` 1.4.7 to 2.0, `langgraph` 1.2 to 2.0. Outside that range the adapter still installs and warns once. + +## Instrument + +```python +import failproofai_sdk + +failproofai_sdk.configure(environment="production") +failproofai_sdk.instrument() + +with failproofai_sdk.session(): + graph.invoke({"messages": [HumanMessage("...")]}) +``` + +`instrument()` registers a tracer through `langchain_core.tracers.context.register_configure_hook`. LangChain injects it into every callback manager it builds, so graphs, tools, and models are captured without changing a call site — including ones inside libraries you did not write. + +## What gets recorded + +| LangChain or LangGraph | Failproof event | +| --- | --- | +| Root run | `agent_start`, `agent_end` | +| LangGraph node | `hook_triggered`, `hook_completed` | +| Compiled subgraph | Nested `agent_start`, `agent_end` | +| Tool run | `tool_use`, `tool_result` | +| Retriever run | `tool_use`, `tool_result`, output summarized | +| Chat model or LLM run | `model_request`, `model_response`, with token usage | +| Streamed tokens | Folded into the response as chunk count and time to first token. Token counts need `ChatOpenAI(stream_usage=True)` — see below | +| `interrupt()` | `human_wait`, `agent_pause` | +| `Command(resume=...)` | `agent_resume`, `human_input`, correlated on the `Interrupt.id` — including when the resume happens in a different process against the same checkpointer | +| Unhandled exception | `error`, then `agent_end` with outcome `failed` | + +**A node becomes a hook, not a nested agent.** `agent_id` is the primary facet across every dashboard surface — promoting `retrieve`, `grade_documents` and `should_continue` to agents would drown it, and label the session after whichever node happened to run first. + +Hook spans render the same way and still give you a per-node latency view. + + + **Name your nodes whatever you like.** A node's run is identified by its *shape* — a non-leaf run carrying LangGraph's own step tag — never by its name. + + +| You write | What gets recorded | +| --- | --- | +| `add_node("lookup_population", ToolNode([...]))` | The tool | +| `add_node("ChatOpenAI", ...)` | The model call | + +Naming a node after the thing it runs used to make that thing's events disappear. It no longer does. + +### Streaming + +`.stream()` and `.astream()` emit no per-token events. They fold into the closing `model_response`: + +| Field | Carries | +| --- | --- | +| `fw_chunks` | How many chunks arrived | +| `fw_ttft_ms` | Time to first token | + +### Token counts on a streamed response + +Separate matter, and easy to miss: OpenAI only sends usage on a streamed response **when asked**. + +```python +ChatOpenAI(model="gpt-4o-mini", stream_usage=True) # without this, no tokens +``` + +The adapter records what the framework hands it. Without that flag there is nothing to record, and `model_response` arrives with no token counts. + +## Example + +```python +import failproofai_sdk +from langchain_core.messages import HumanMessage +from langchain_core.tools import tool +from langchain_openai import ChatOpenAI +from langgraph.prebuilt import ToolNode, create_react_agent + +failproofai_sdk.configure(environment="production") +failproofai_sdk.instrument() + + +@tool +def price_of(item: str) -> float: + """Return the unit price of an item in USD.""" + return {"widget": 42.0, "gadget": 17.5}[item.lower().strip()] + + +@tool +def stock_of(item: str) -> int: + """Return the units of an item currently in stock.""" + return {"widget": 120, "gadget": 0}[item.lower().strip()] + + +tools = ToolNode([price_of, stock_of], handle_tool_errors=True) +graph = create_react_agent(ChatOpenAI(model="gpt-4o-mini"), tools) + +with failproofai_sdk.session(): + with failproofai_sdk.agent("analyst", goal="price and stock report"): + result = graph.invoke({ + "messages": [HumanMessage("Price and stock for widget and gadget?")] + }) +``` + +## Name your spans + +By default the root span takes the graph's own name. Wrap it to get a label you chose: + +```python +with failproofai_sdk.session(): + with failproofai_sdk.agent("analyst", goal="price and stock report"): + graph.invoke(...) +``` + +For multi-agent setups, nest the scopes. Each worker becomes a child span carrying `parent_id`: + +```python +with failproofai_sdk.session(): + with failproofai_sdk.agent("supervisor"): + with failproofai_sdk.agent("researcher"): + research_graph.invoke(...) + with failproofai_sdk.agent("writer"): + writer_graph.invoke(...) +``` + +Keep `agent_id` low cardinality. Use a role or node name, never a UUID or a per-run string. + +## Control the session + +The session id resolves in this order, first match winning: + +1. `instrument("langchain", session_id=...)` +2. `config={"metadata": {"failproofai_sdk_session_id": ...}}` +3. The enclosing `failproofai_sdk.session()` scope +4. `metadata["session_id"]`, `metadata["conversation_id"]`, or `metadata["thread_id"]` +5. The root run id + +It is never generated from scratch, because a synthesized id splits one run across several sessions. + +```python +graph.invoke( + {"messages": [...]}, + config={"metadata": {"failproofai_sdk_session_id": f"chat-{user_id}"}}, +) +``` + +## Options + +```python +failproofai_sdk.instrument( + "langchain", + session_id=None, # pin every run to one session id + include_chains=set(), # allowlist intermediate chains as hook pairs + capture_content=True, # False drops prompts and completions from payloads + graph_callbacks=True, # first-class interrupt and resume, needs langgraph 1.2+ +) +``` + +Set `capture_content=False` for regulated data. Structure, timings, token counts, tool names, and outcomes are still recorded; message bodies are not. + +`include_chains` applies to **nested** runs only. A runnable you invoke at the top level is the session's root, so it becomes the agent span rather than a hook pair, and naming it here has no effect. + +## Human in the loop + +`interrupt()` produces four events, and neither pair is redundant: + +```python +from langgraph.types import Command, interrupt + +def approve(state): + decision = interrupt({"prompt": "Ship it?", "options": ["yes", "no"]}) + return {"approved": decision == "yes"} + +with failproofai_sdk.session(): + graph.invoke(state, config) # human_wait, agent_pause + graph.invoke(Command(resume="yes"), config) # agent_resume, human_input +``` + +`human_wait` to `human_input` carries the prompt and the answer. `agent_pause` to `agent_resume` is the only pair that feeds paused time, so without it a ten-minute human wait is billed as active agent time. The root span stays open across the gap, keeping both calls in one session. + +## Common problems + + + + `create_react_agent` propagates the exception. To let the model see the failure and continue, build the tool node explicitly: + + ```python + from langgraph.prebuilt import ToolNode, create_react_agent + + tools = ToolNode([price_of, stock_of], handle_tool_errors=True) + graph = create_react_agent(model, tools) + ``` + + The failure is recorded as a `tool_result` carrying an error either way. This only decides whether the run survives it. + + + + A direct `llm.invoke()` outside any graph has no parent run, so it opens a root span and emits its model pair inside it. The dashboard parents leaves to an open agent, so the span is deliberate. Name it: + + ```python + with failproofai_sdk.agent("summariser"): + summary = ChatOpenAI(model="gpt-4o-mini").invoke([HumanMessage(text)]) + ``` + + + + You passed a Failproof handler in `config={"callbacks": [...]}` as well as calling `instrument()`. Remove it. The configure hook already covers every callback manager in the process. + + + + They do not. LangGraph raises `GraphInterrupt` through the same path as a real exception, so every pause reaches the tracer as an error callback. Any `GraphBubbleUp` subclass is treated as control flow instead, so an approval does not paint a red error. + + + + Check in this order: `instrument()` ran before the graph executed; there is a `with failproofai_sdk.session():` around the call; `FAILPROOFAI_SDK_STRICT=1` set, so a degraded hook raises instead of being swallowed. + + + +## Next + + + + Pairs, ids, session lifecycle, and delivery. + + + Follow causality through the session you just captured. + + + CrewAI, LlamaIndex, Pydantic AI, and custom agents. + + diff --git a/docs/start/integrations/llamaindex.mdx b/docs/start/integrations/llamaindex.mdx new file mode 100644 index 000000000..fbc494c61 --- /dev/null +++ b/docs/start/integrations/llamaindex.mdx @@ -0,0 +1,245 @@ +--- +title: "LlamaIndex" +sidebarTitle: "LlamaIndex" +description: "Instrument workflows, steps, function agents, and retrievers." +icon: "database" +--- + +## Install + +```bash +pip install 'failproofai-sdk[llamaindex]' +``` + +Supported: `llama-index-core` 0.14.23 to 0.15. 0.14.23 is the release where the workflow stream started carrying the typed agent events this adapter reads. Below it, model names and agent structure both go missing. + +## Instrument + +```python +import asyncio + +import failproofai_sdk + +failproofai_sdk.configure(environment="production") +failproofai_sdk.instrument() + + +async def main(): + async with failproofai_sdk.session(): + await agent.run("...") + + +asyncio.run(main()) +``` + +LlamaIndex's agent API is async. Every scope works under `async with` as well as `with` and produces identical events. + +`instrument()` attaches an event handler and a span handler to LlamaIndex's global dispatcher. Together they make the agent loop visible, not just its model calls. + + + Without one extra argument on your LLM, every token count in your trace is null. See [Token counts](#token-counts) below. + + +## Token counts + +`FunctionAgent` calls `astream_chat`, and `llama-index-llms-openai` does not send `stream_options={"include_usage": True}` when it streams. The provider therefore never sends the usage chunk, and there is nothing for any instrumentation to read. + +This is upstream LlamaIndex behavior. Opt in on your LLM: + +```python +from llama_index.llms.openai import OpenAI + +llm = OpenAI( + model="gpt-4o-mini", + additional_kwargs={"stream_options": {"include_usage": True}}, +) +``` + +Measured on the same run and model: + +| | Input tokens | Output tokens | +| --- | --- | --- | +| Without | `null` | `null` | +| With | 148 | 17 | + +Non-streaming calls (`llm.chat`, `llm.achat`) report usage with no configuration. Only the streaming path, which is the default agent path, needs this. + +## What gets recorded + +| LlamaIndex | Failproof event | +| --- | --- | +| `Workflow.run` root span | Session, `agent_start`, `agent_end` | +| Nested `Workflow.run` span | Nested `agent_start`, `agent_end` | +| Workflow step span | `hook_triggered`, `hook_completed` | +| LLM chat start and end | `model_request`, `model_response` | +| `FunctionTool.call` span | `tool_use`, `tool_result` | +| Retrieval start and end | `tool_use`, `tool_result`, output summarized | +| Embeddings | Nothing, unless `embeddings=True` | +| A tool waiting on a person | `human_wait`, `agent_pause`, then `agent_resume`, `human_input` | +| `AgentWorkflow` handoff | A nested `agent_start`, `agent_end` per agent, parented to the workflow | +| Exception | `error`, then `agent_end` with outcome `failed`, and `agent_end.summary` naming it | +| `handler.cancel_run()` | `agent_end` with outcome `cancelled` and no `error` — a stop button is not a failure | + +`agent_id` is the `FunctionAgent.name` when you set one, and the workflow class name otherwise. Under an `AgentWorkflow`, each agent that takes a turn gets its own nested span under the workflow, so a handoff reads as two agents rather than one. + +Retrieval output is summarized rather than dumped. A retriever returns documents, and storing them in the payload would put your corpus in the events store once per query. The count, score range, and truncated snippets are kept instead. + +## Example + +```python +import asyncio + +import failproofai_sdk +from llama_index.core.agent.workflow import FunctionAgent +from llama_index.core.tools import FunctionTool +from llama_index.llms.openai import OpenAI + +failproofai_sdk.configure(environment="production") +failproofai_sdk.instrument() + +POP = {"tokyo": "37M", "delhi": "33M"} +AREA = {"tokyo": "2,194 km2", "delhi": "1,484 km2"} + + +def population(city: str) -> str: + """Population of a city. Valid: tokyo, delhi.""" + return POP.get(city.lower().strip(), "unknown") + + +def area(city: str) -> str: + """Land area of a city. Valid: tokyo, delhi.""" + return AREA.get(city.lower().strip(), "unknown") + + +async def main(): + agent = FunctionAgent( + name="city_analyst", + tools=[ + FunctionTool.from_defaults(fn=population), + FunctionTool.from_defaults(fn=area), + ], + llm=OpenAI( + model="gpt-4o-mini", + additional_kwargs={"stream_options": {"include_usage": True}}, + ), + system_prompt="Use the tools. Be terse.", + ) + + async with failproofai_sdk.session(): + async with failproofai_sdk.agent("city_analyst", goal="compare two cities"): + print(await agent.run("Compare Tokyo and Delhi on population and area.")) + + +asyncio.run(main()) +``` + +The agent loop appears in the trace as hook pairs: `init_run`, `setup_agent`, `run_agent_step`, `parse_agent_output`, `call_tool`, and `aggregate_tool_results`. They are the framework's own loop, so they are hooks rather than agents, which keeps `agent_id` meaningful. + +## Name your spans + +`agent_id` is the `FunctionAgent.name` when you set one, and the workflow class name otherwise. + +```python +FunctionAgent(name="city_analyst", tools=[...], llm=llm) # agent_id = "city_analyst" +``` + +In an `AgentWorkflow`, that name is also what each handoff is recorded under: + +```text +AgentWorkflow parent span +├─ city_analyst turn 1 +├─ cost_analyst turn 2 +└─ city_analyst turn 3 — a new turn, not a reopened one +``` + +So `agent_id` tells you **which agent** did the work and `parent_id` tells you **which workflow** it belonged to. An agent handed control back later opens a second turn rather than reopening its first. + +Wrap the run to override it, or to group several agents under one parent: + +```python +async with failproofai_sdk.agent("research", goal="compare two cities"): + await agent.run(...) +``` + +Keep `agent_id` low cardinality. It is the primary facet on every dashboard surface, so use a role or workflow name, never a UUID or per-run string. + +## Control the session + +This adapter takes **no `session_id` option**. The session comes from the enclosing scope, and otherwise a generated `uuid4().hex` per workflow run: + +```python +async with failproofai_sdk.session(f"chat-{user_id}"): + await agent.run(...) +``` + +## Options + +```python +failproofai_sdk.instrument( + "llama_index", + embeddings=False, # True records embedding calls as tool pairs + steps=True, # False drops workflow-step hook pairs + capture_messages=True, # False drops prompts and system text from payloads + stale_after=600.0, # seconds before an abandoned span is force-closed + reaper_interval=30.0, # how often the reaper sweeps; 0 disables it +) +``` + +| Option | Why you would change it | +| --- | --- | +| `embeddings` | Turn on only when debugging embedding latency or cost. A bulk index build is thousands of calls and will bury the timeline. | +| `steps` | Turn off if you only want model and tool events and find the agent loop noisy. | +| `capture_messages` | Turn off for regulated data. Structure, timings, tokens, and outcomes are still recorded. | +| `stale_after` | A workflow that never finishes leaves an open span. The reaper force-closes it after this many seconds so the session settles instead of reading `ongoing` forever. | +| `reaper_interval` | Sweep frequency. Set to `0` to disable the reaper entirely. | + +## Human in the loop + +Captured when the wait happens inside a tool: + +```python +async def ask_human(question: str) -> str: + """Ask a person and wait for their answer.""" + response = await ctx.wait_for_event(HumanResponseEvent) + return response.answer +``` + +`ctx.wait_for_event` in a plain workflow step is not captured. The runtime catches the drop before it reaches the dispatcher, so the step exits and re-runs later with no signal to key a pause on. The FunctionAgent pattern, which LlamaIndex documents, waits inside a tool and is captured in full. + +## Common problems + + + + Add `additional_kwargs={"stream_options": {"include_usage": True}}` to your LLM. See [Token counts](#token-counts). + + + + LlamaIndex has no standard usage field. The adapter tries several known shapes, and an integration that names its counters something new will not match any of them. + + The raw dict always ships, so check `usage` in the payload to see what your provider called them. + + A populated `usage` alongside empty token columns is deliberate — it beats a confident wrong number. + + + + That is the FunctionAgent loop, one set per iteration. Filter by hook name on the dashboard. These step timings are usually the reason to use this adapter rather than a model-only one. + + + + Check in this order: `instrument()` ran before the run; there is an `async with failproofai_sdk.session():` around the `await`; `llama-index-core` is 0.14.23 or newer; `FAILPROOFAI_SDK_STRICT=1` set, so a degraded hook raises instead of being swallowed. + + + +## Next + + + + Pairs, ids, session lifecycle, and delivery. + + + Follow causality through the session you just captured. + + + LangGraph, CrewAI, Pydantic AI, and custom agents. + + diff --git a/docs/start/integrations/pydantic-ai.mdx b/docs/start/integrations/pydantic-ai.mdx new file mode 100644 index 000000000..23f4d0bba --- /dev/null +++ b/docs/start/integrations/pydantic-ai.mdx @@ -0,0 +1,209 @@ +--- +title: "Pydantic AI" +sidebarTitle: "Pydantic AI" +description: "Instrument typed agents, tools, model calls, and retries." +icon: "badge-check" +--- + +## Install + +```bash +pip install 'failproofai-sdk[pydantic-ai]' +``` + +Supported: `pydantic-ai-slim` 2.0 to 3.0. 2.0 removed `Agent(instrument=...)` and introduced the capability protocol this adapter is built on, so 1.x cannot be instrumented this way. + +## Instrument + +```python +import failproofai_sdk +from pydantic_ai import Agent + +failproofai_sdk.configure(environment="production") +failproofai_sdk.instrument() # before constructing any Agent + +agent = Agent("openai:gpt-4o-mini", system_prompt="Be terse.") + +with failproofai_sdk.session(): + result = agent.run_sync("...") +``` + + + `instrument()` must run before you construct an `Agent`. The capability is appended at construction, so an agent built earlier carries none and records nothing, with no error because nothing went wrong. This is the most common cause of an empty trace with this adapter. + + +Module-scope agents are where this bites: + +```python +# agents.py +agent = Agent("openai:gpt-4o-mini") # constructed at import time + +# main.py +import failproofai_sdk +failproofai_sdk.instrument() # run this FIRST +import agents # now the agent gets the capability +``` + +Confirm it took: + +```python +print([type(c).__name__ for c in agent.root_capability.capabilities]) +# ['FailproofAI', 'ToolSearch', 'PendingMessageDrainCapability'] +``` + +Pydantic AI merges the list you pass into a single `root_capability`, so there is +no `agent.capabilities` attribute to read. + +Agents built while instrumented keep the capability, so you can `uninstrument()` and re-instrument without rebuilding them. + +## What gets recorded + +| Pydantic AI | Failproof event | +| --- | --- | +| Agent run | `agent_start`, `agent_end` | +| Model request | `model_request`, `model_response`, with token usage | +| Tool call | `tool_use`, `tool_result`, with the arguments the model sent | +| `ModelRetry` from a tool | `tool_result` carrying an error | +| Unhandled exception | `error`, then `agent_end` with outcome `failed` | + +There is no hook pair and no human-in-the-loop pair here. Pydantic AI has no node or step boundary to bracket and no built-in human pause, so there is nothing to map. If you build either, emit the events yourself — see [Custom agents](/start/integrations/custom-agents). + +`output_type` makes no difference to the trace. A typed run and a string run produce the same events. + +## Example + +```python +import failproofai_sdk +from pydantic import BaseModel +from pydantic_ai import Agent, ModelRetry + +failproofai_sdk.configure(environment="production") +failproofai_sdk.instrument() + +PRICE = {"widget": 42.0, "gadget": 17.5} +STOCK = {"widget": 120, "gadget": 0} + + +class Report(BaseModel): + headline: str + out_of_stock: list[str] + + +agent = Agent( + "openai:gpt-4o-mini", + output_type=Report, + system_prompt="Use the tools for every number. If a tool fails, note it and continue.", +) + + +@agent.tool_plain +def price_of(item: str) -> float: + """Unit price of an item. Valid: widget, gadget.""" + return PRICE[item.lower().strip()] + + +@agent.tool_plain +def stock_of(item: str) -> int: + """Units in stock. Valid: widget, gadget.""" + return STOCK[item.lower().strip()] + + +@agent.tool_plain +def restock_eta(item: str) -> str: + """Restock ETA. Not available.""" + raise ModelRetry(f"no restock schedule for {item!r} — answer without it") + + +with failproofai_sdk.session(): + with failproofai_sdk.agent("inventory", goal="stock report"): + result = agent.run_sync( + "For widget and gadget, get price and stock. " + "For anything out of stock, try the restock ETA. Then produce the report." + ) +``` + +In the trace, `restock_eta` appears as a `tool_result` carrying an error, followed by another model call where the agent works around it, and the run still ends `success`. Both facts are kept. + +## Errors, retries, and control flow + +Pydantic AI raises exceptions for three different things, and the adapter separates them: + +| Exception | Treated as | Result | +| --- | --- | --- | +| `ModelRetry`, `ToolRetryError`, `ToolFailedError` | A real tool failure | `tool_result` with an error; the run can still end `success` | +| `SkipToolExecution`, `SkipToolValidation`, `SkipModelRequest`, `CallDeferred`, `ApprovalRequired` | Control flow | Not an error; the run is being steered | +| Anything else | A failure | `error`, then `agent_end` with outcome `failed` | + +`ModelRetry` is in the first group deliberately. It means an attempt genuinely failed and the model was asked to try again, which is what a tool span's error field is for. Classifying it as control flow would hide real tool failures behind a green run. + +## Name your spans + +Pydantic AI's own run span is named `agent`. Wrap the call to give it a label you chose: + +```python +with failproofai_sdk.session(): + with failproofai_sdk.agent("inventory", goal="stock report"): + agent.run_sync("...") +``` + +The framework's span then nests under `inventory`, and that is where the model and tool events hang. + +Keep `agent_id` low cardinality. It is the primary facet on every dashboard surface, so use a role name, never a UUID or per-run string. + +## Control the session + +Resolved in this order, first match winning: + +1. `instrument("pydantic_ai", session_id=...)` +2. The enclosing `failproofai_sdk.session()` scope +3. The run's `conversation_id`, then its `run_id` +4. A generated `uuid4().hex` + +```python +with failproofai_sdk.session(f"chat-{user_id}"): + agent.run_sync("...") +``` + +## Options + +```python +failproofai_sdk.instrument( + "pydantic_ai", + session_id=None, # pin every run to one session id + capture_content=True, # False drops prompts and completions from payloads +) +``` + +## Common problems + + + + The `Agent` was constructed before `instrument()` ran. See the warning above, and check `agent.root_capability.capabilities`. + + + + A bare `raise` propagates; that is Pydantic AI's design. To let the model work around it, raise `ModelRetry` with a message it can act on. The failure is recorded either way. + + + + That child is Pydantic AI's own run span, and it is where the model and tool events hang. Drop your own scope if you want a single span, at the cost of the custom name. + + + + Pydantic AI's async graph stack is longer than the payload field limit, and a traceback's last line is the exception itself. This field is trimmed from the front rather than the back, so the line you need survives. + + + +## Next + + + + Pairs, ids, session lifecycle, and delivery. + + + Follow causality through the session you just captured. + + + LangGraph, CrewAI, LlamaIndex, and custom agents. + + diff --git a/fp-cli/.gitignore b/fp-cli/.gitignore new file mode 100644 index 000000000..d18ada78d --- /dev/null +++ b/fp-cli/.gitignore @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.py[cod] +*.egg +*.egg-info/ +dist/ +build/ + +# Virtual environments +.venv/ +venv/ + +# Testing & coverage +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store + +# Claude +CLAUDE.md diff --git a/fp-cli/CHANGELOG.md b/fp-cli/CHANGELOG.md new file mode 100644 index 000000000..2ba281a4e --- /dev/null +++ b/fp-cli/CHANGELOG.md @@ -0,0 +1,387 @@ +# Changelog — `fp` CLI + +## Unreleased + +### The session moved to `~/.failproofai/fpcli/cli-auth.json` + +- **Nothing to do. Nobody is signed out.** The session was at `~/.fp/cli.json`; it is now + `~/.failproofai/fpcli/cli-auth.json`, still mode `0600`. A session at the old path is + adopted on the next command, so an upgrade is invisible. +- **Adoption is a COPY.** The old file is left exactly where it is, which keeps a + downgrade working — an older `fp` still finds its session where it left it — and means + nothing irreversible happens on a machine mid-rollout. Remove `~/.fp/` yourself once + you are happy. +- **It is best-effort on purpose.** If the new location cannot be written (read-only + home, full disk, a symlink we refuse) the session found is still returned, so a machine + that cannot be migrated keeps working rather than being logged out by our own + housekeeping. +- **Why:** one product owned three top-level dotfiles — `~/.fp` (this CLI), + `~/.failproofai` (the Enforcement CLI) and `~/.agenteye` (the SDK/collector spool). + This collapses the first into the second. `~/.agenteye` stays: it is a wire contract + with the collector, and renaming it from the SDK's side would write events into a + directory nothing watches, with no error on either side. +- **`FP_HOME` still works and still wins.** Resolution is `FP_HOME` > + `$FAILPROOFAI_HOME/fpcli` > `~/.failproofai/fpcli`. `FP_HOME` names the CLI's own + directory and is used as-is, so an existing export addresses the same place it always + did; `FAILPROOFAI_HOME` names the shared home root, so `fpcli/` is appended. The + filename changed too (`cli.json` → `cli-auth.json`), so an `FP_HOME` user's old session + sits beside the new path and is adopted from there. A redirected config is never + reached past: with `FP_HOME` set the CLI does not look in `~/.fp` at all, because that + would adopt a session from a context the user explicitly moved away from. +- **Nothing that authenticates without the config file changed.** `--token` / `FP_TOKEN` + and `--api-key` / `FP_API_KEY` never touched disk and still do not, so CI that + authenticates by environment never enters any of this. Read-only commands still create + no file at all. +- **The CLI only ever creates.** It will bring `~/.failproofai` into existence on a + machine that has never run the Enforcement CLI, and leaves a populated one untouched. + The new path is registered in that home's layout (`src/hooks/fp-home.ts`) and + classified `user-typed`, so a layout migration cannot drop it — `resettablePaths()` is + a filter over that table, and only `derived` / `refetchable` entries are deleted. No + `LAYOUT_VERSION` bump: nothing moved, and a bump would mark every existing home stale + and run a reset on machines that have nothing to migrate. + +### Two hardenings the shared directory made necessary + +Both are consequences of writing next to another product's secrets rather than +into a directory the CLI owned outright. + +- **A symlinked config no longer writes through to whatever it points at.** + `O_TRUNC` follows symlinks, so a link at `cli-auth.json` pointing at + `../credentials.json` made `fp login` silently truncate the Enforcement CLI's + token and write the session over it — no error, nothing in either product's + logs. The final component is now opened `O_NOFOLLOW` and a link is refused + with a message naming the file. The link is not deleted: a symlink is + something a person put there. +- **`fpcli/` is created `0700` rather than inheriting the umask.** A common + `0002` umask made it `0775`. The session file itself was always `0600`, so it + was never readable — but a group-writable directory lets anyone in the group + replace the file, which is a session swap. The shared parent is untouched: if + the CLI is the first to create `~/.failproofai` it leaves it to the umask, as + the Enforcement CLI would, and an existing directory is never re-permissioned. +- **The session is written atomically, to a temp file that is renamed into place.** + `O_NOFOLLOW` closed the symlink hole but says nothing about a **hard link** — + that is not a link, it is a second name for one inode, so an in-place write + went straight through it into the neighbour's file exactly as before. `rename` + swaps the directory entry instead, so the other name keeps the old inode. The + same change buys three more things: a reader never observes a half-written + credential, two racing `fp` processes end with one whole session rather than a + splice, and a FIFO left in the config position can no longer hang the CLI + forever (`open` on a FIFO blocks for a reader; the previous code sat there + indefinitely with no output). The temp file is removed on every failure path. + +## 0.1.7 + +### Packaging fix: declare `click` and `pygments` explicitly +- **Fixes `ModuleNotFoundError: No module named 'click'` on a fresh install.** The CLI imports + `click` directly (and `pygments` for SQL highlighting) but only ever got them transitively via + `typer` / `rich`. Modern `typer` (>=0.13) **no longer installs `click`**, so a clean + `pipx install fp-cli` left `click` absent and the CLI crashed on startup. Both are now listed + as direct dependencies (`click>=8.1`, `pygments>=2.13`) so installs are self-contained. +- No behaviour change — same code, correct dependency metadata. + +### `keys create` / `keys update`: compact permission format + shared ending +- **New permission input**: the compact `slug:action.action` token format, space-separated and/or + repeated `-p` (both compose) — `keys create "ci-bot" -p events:read.add keys:read` expands to + `events:read, events:add, keys:read` (de-duped). A malformed token (`events`, `events:`) or an + unknown permission → a red `✗ bad permission …` box **before** any mutation (exit 2). Replaces + the old one-permission-per-`-p` form. +- **`keys update` now takes the unique key NAME** (resolved to the id), not a UUID — consistent + with `regenerate`/`disable`. Permissions REPLACE the current grants; a calm confirm (`replace + permissions on key ? the key keeps working …`, default no, `--yes` skips); decline → faint + `cancelled — permissions unchanged` box. +- **Shared ending** (both): a green `✓ created/updated key · {n} permissions` line + the + **same grouped permissions box** as `whoami` / `orgs perms`. `create` shows a `secret · shown + once` green box first (piping still captures just the bare secret); name-collision → red box. +- `--json`: create `{id,name,permissions,created_at,key}`, update `{id,name,permissions,created_at, + revoked_at}` — `permissions` is the expanded flat list. + +### `keys`: boxed `list` (active-first), and name-based `regenerate` / `disable` flows +- **`keys list`** → a boxed `api keys · {n} · active first` table (`created · name · permissions · + status`) — **active keys sort to the top** (then revoked), each group newest-first. Status is + colour-coded (`● active` green / `○ revoked` red; filled = live, hollow = dead) with a + `{total} keys · {n} active · {m} revoked` footer. The raw UUID is hidden by default (`--show-id` + for a short id, `--json` for the full id); created shows compact `MM-DD HH:MM`. `--json` unchanged. +- **`keys regenerate ` / `keys disable `** now take the **unique key name** (resolved + to the id), not a UUID. Every flow state is a **rounded notice box** (one consistent boxed + family): an amber `confirm` box (`⚠ … ?` + dim consequence) with a `[y/N]` prompt below + defaulting to **no**; a faint `cancelled` box (`○ nothing changed`) on decline (exit 0, no more + red "Aborted"); a red `error` box (`✗ no key named ` + hint) when not found (exit 6); a + green `secret rotated` box (regenerate — piping still captures just the bare secret on stdout) + or `disabled` box (disable); a faint `no change` box when already disabled. `--json`: regenerate + → `{name, secret}`, disable → `{name, status:"disabled"}`, decline → `{cancelled:true}`. +- Shared helpers `confirm_destructive` + `print_cancelled` + `_notice_box` (reused by the + destructive key actions). `keys create` / `keys update` are unchanged (separate pass). + +### `list `: one boxed, column-flowing view +All nine `list` subcommands (envs, agents, event_types, score_filters, models, hooks, triggers, +tools, error_types) now render through one shared boxed renderer (`render_value_list`): +- An ACCENT panel titled `{kind} · {count} {description}` with the (sorted) values in + **column-major flow** — a column fills to 8 rows then overflows to the next; the column count + is capped to the terminal width (prefers taller over wider-than-screen; never overflows; min + one column). Empty → `none found`. +- A dim **filter-hint footer** for kinds that map to a real filter flag: `envs`/`agents`/ + `event_types` → `fp events --env/--agent-id/--event-type`, `error_types` → + `fp errors --error-type`. Kinds with no matching value filter (models/hooks/triggers/ + tools/score_filters) get no footer. +- `--json` is unchanged (`{"kind", "values"}`). + +### `orgs`: boxed `list`, a `current` identity card, and a new `orgs perms` +- **`orgs list`** now renders the same boxed `your orgs · N` panel as `whoami` (marker / org / + name / role / perms + a `switch with …` line) instead of a plain table. +- **`orgs current`** is a compact `current org` identity card (slug + name · role + permission + count · signed-in email) with a footer cross-linking `orgs perms` / `orgs switch`. The + 28-permission comma wall moved out. `--json`: `{slug, name, role, permission_count, user_email}`. +- **New `orgs perms`** — your grants in the active org as the grouped, risk-coloured permissions + panel (the **same shared renderer** as `whoami`: read=green, create/modify=pink, invoke=amber, + destructive=red; NO_COLOR `*`). `--json`: `{slug, role, permissions, permission_count}`. +- The permissions panel and the orgs panel are now single shared helpers + (`render_permissions_panel` / `render_orgs_panel`) used by `whoami`, `orgs perms`, and + `orgs list` so they can't drift. Resource order aligned (dashboards · keys · queries · users · …). + +### `errors` now lists the errored events (with `--aggregate` for the old summary) +`errors` was a summary-only command; it now **fetches and lists the errored events** (the +dashboard `/errors` view), with the rollup moved behind a flag — mirroring `evals`. +- **`errors`** (default) — one row per errored event in an error-themed (muted red-purple) + boxed table: `time · event · env · agent · session · summary`. The `event` cell is `● {type}`, + **red only when the type names an error** (`error`/`fail`); the `summary` is derived from the + payload (tool name, `error_type: message`, hook name, …) and truncates with `…`. Session ids + truncate (`--full-ids` for whole). Same filter/paging options as `events`/`sessions` plus the + errors-specific `--error-type`/`--search-exclude`. +- **`errors --aggregate`** — the old summary, redesigned as an errors-themed card: a large red + hero count + `across N sessions · N agents · last `; zero errors → a calm green + `✓ no errors found` in a neutral panel. +- `--json`: list → `{"errors": [...], "next_cursor": …}` (full event rows); aggregate → `{total, + sessions, agents, last_ts, bins}`. Re-added `errored`/`error_type`/`search_exclude` to the + `list_events` client. `analytics_cmds.py` renamed → `errors_cmds.py`. + +### Score bar → braille + unified colour bands +- The `evals --aggregate` score bar now uses **braille** (`⣿` fill / `⣀` track) on a **zoomed + `.40–1.0` scale** (so the typical .7–.9 range shows visible variation), with a band-tinted + track. Falls back to solid blocks (`█`/`░`) under `NO_COLOR` (clearer in mono; failing avgs + get a trailing `!`). +- **Unified score colour bands** (`.80`/`.50`, was `.85`/`.70`) used **everywhere** a score is + coloured — the evals score cells, the aggregate avg, and the bar: **≥.80** cyan-green + `#3ddbb8`, **.50–.80** amber, **<.50** red. So a given score reads the same colour CLI-wide. +- Aggregate gains a one-line legend under the panel: `scale .40–1.0 · ⣿ ≥.80 ⣿ .50–.80 ⣿ <.50`; + the evals list footer legend updates to the new bands. Presentation only. + +### Split scores back out: `sessions` (runs) vs `evals` (scores + `--aggregate`) +Refined the previous merge into a clearer operational-vs-quality split: +- **`sessions`** is now a pure run list — columns `time · env · agent · session · status` + (the **scores column is gone**, and so are `--score`/`--scores-full`). Footer has no score + legend. `--json` still includes `scores` per row. +- **`evals`** (renamed from `eval-aggregate`, now a full command) has **two modes, same filters**: + - bare → the eval **list** with the scores column (`time · env · agent · session · status · scores`). + - **`--aggregate`** → a redesigned two-panel view: a **totals card** (hero count + colour-coded + status dots + a derived success-rate line) and a **score-stats table** (per metric: n, avg + + a 10-cell threshold-coloured bar, min/max/p50), sorted worst-average first, all metrics shown. + - `--json`: list → `{"evaluations": [...], "next_cursor": …}`; aggregate → `{total, + status_counts, score_stats[], timeline}`. +- The standalone `eval-aggregate` command is removed (use `evals --aggregate`). Aggregate numbers + are rounded to 2 decimals for display (full precision stays in `--json`); under `NO_COLOR` a + failing avg (<.70) gets a trailing `!`. No change to what's computed or fetched. + +### Merged `evals` into `sessions` (one command, not two) +`evals` and `sessions` both listed evaluation results from the same endpoint, so they're now +**one command: `sessions`**. The richer `evals` implementation (boxed output, full option set, +width-aware scores) was kept and renamed; the old `sessions` command was removed. +- `sessions` lists evaluation results newest-first with the boxed renderer (status colours, + threshold-coloured scores, session-id truncation, `--full-ids`/`--scores-full`, score legend). +- `--json` returns `{"sessions": [...], "next_cursor": ...}`. +- The dead `list_sessions` client helper was removed. (`eval-aggregate` is unchanged.) +- Help/schema/README scrubbed of `evals`; top-level examples fixed to put the global `--json` + before the command. + +### Validation hardening (`--score` / `--from` / `--to`) +- **`--score KEY:..` (both bounds empty) is now a clean usage error (exit 2).** It used to pass + validation and the server silently dropped it → the **unfiltered** set came back. +- **`--from`/`--to` now require a full RFC3339 UTC timestamp** (a `T` separator + a `Z`/offset). + A timezone-less (`2026-05-01T00:00:00`) or space-separated value used to slip through and hit + a server 400 (exit 1); it's now caught client-side as a usage error (exit 2). + +### Redesigned the `evals` table +- **New default output**: the same accent-bordered rounded panel as `events`, built on a shared + `render_list_panel` helper. Columns `time · env · agent · session · status · scores` (agent + bright, the rest contextual-dim). **status** is colour-coded by state (done green / running + amber / failed-error-timeout red); **scores** render as `metric value` with the value coloured + by threshold (**≥.85** green / **.70–.85** amber / **<.70** red) and compacted (`.94`, `1.0`). +- **Scores are width-aware**: as many pairs as fit, then `+N`; an eval always stays one row and + the `time`/`status` columns never get squeezed. `--scores-full` shows every pair (may wrap). +- **Session ids truncate** by default (`sess-…fcf97e01`); `--full-ids` keeps them whole. `--json` + always has full ids + structured scores. +- **Footer** carries a compact score-colour legend (`score: ≥.85 .70–.85 <.70`), dropped on a + narrow terminal. Under `NO_COLOR`, failing scores (<.70) get a trailing `!` so they stay visible. + +### `evals`: fix docs, reorder options, drop `--latest-per-session` +- **Fixed the broken `--json` examples** in the `evals` help — `--json` is a **global** option and + goes before the command (`fp --json evals …`), not after it (`fp evals … --json`, + which exits 2). The docstring examples now show the correct form. +- **Reordered the options** to match the `events -h` layout: limit, since, from, to, session-id, + env, agent-id, status, score, all, cursor, page-size, fields. +- **Removed `--latest-per-session`** — use `fp sessions` for the newest evaluation per + session (it's the same query, deduped). The flag's client param is retained internally (the + `sessions` command still forces it). + +### Redesigned the `events` table + trimmed its filters +- **New default output**: an accent-bordered rounded box whose title carries the row count, + sort direction and date (`events · 10 · newest first · 2026-06-22`), with columns + `time · type · env · agent · session` (now sharing the `render_list_panel` helper with `evals`: + a dim LABEL header + a thin rule). Rows show clock time (the date lives in the title); a + window spanning more than one UTC day adds the date back into each row. The summary line below + reads ` shown · more available · fp events --all`. `--json` is unchanged; `--fields` + still prints the plain projected table. +- **Removed filters**: `--tool-name`, `--error-type`, `--errored`, `--search-exclude` (the + `errors` command keeps the error/exclude filters). The now-orphaned `list_events` client params + were dropped too. +- **`--environment` dropped in favour of `--env`** (one spelling). `evals`/`sessions`/`errors` + still accept both. + +### Replaced `facets` with `fp list ` +The single `fp facets --kind ` command is now a `list` group with one named +subcommand per dropdown, matching the dashboard's filters: +`list envs | agents | event_types | score_filters | models | hooks | triggers | tools | error_types`. +Each prints a flat list of the distinct values (or `--json {"kind", "values"}`) — the same +per-org cached data behind the dashboard dropdowns. **New: `list score_filters`** surfaces the +evaluation score keys/metrics (needs `evaluations:read`; the others need `events:read`). + +### Slimmed the command surface (removed 7 commands) +Trimmed the CLI to a focused, maintainable core — each of these is still available in the +dashboard: +- **`environments`**, **`latency`**, **`score-keys`** — niche analytics one-offs; use `facets` + (value discovery), `errors`, and `eval-aggregate` instead. +- **`session`** (the singular `session show` / `session export` group) — inspect a single session + with `sessions`, `events --session-id `, and `evals --session-id `. +- **`re-evaluate`** — redundant; trigger a fresh evaluation from the dashboard. +- **`permission-sets`** (list/show/create/update/delete) — manage sets in the dashboard; + `users`/`keys` still accept a set by name via `--permission-set`. +- **`dashboards`** (list/show/create/update/delete/tiles) — manage dashboards in the dashboard UI. + +The orphaned client/model plumbing and their unit tests were removed alongside the commands. + +### Reworked the `agent` command surface (chats + models; `compose-sql` removed) +- Dropped the `conversation-` prefix and renamed the list: **`conversations` → `chats`**, + `conversation-show` → **`show`**, `conversation-rename` → **`rename`**, `conversation-delete` + → **`delete`**. (`agent chats --json` now returns `{"chats": [...]}`.) +- **Removed `agent conversation-create`** — `ask` manages the chat lifecycle itself now. +- **`agent ask` is chat-first.** With **`--chat `** it continues that chat (prior thread + sent for context, new turn appended); **without it** it starts a **new** chat, answers, + persists, and prints the new `chat_id` (the first question auto-titles it). The chat is created + only once an answer lands, so a failed/aborted ask leaves no empty chat. (`--conversation` → + **`--chat`**; `--json` now includes `chat_id`.) +- **New `agent models`** — lists the deployment's model allowlist (default marked), read from the + agent health endpoint. **`agent ask --model`** is now validated against that allowlist + client-side (an unknown model exits 2 with the valid choices) instead of silently falling back + to the default. +- **Removed `agent compose-sql`** from the CLI. + +### Fixed: `alerts update` single-field edits (were HTTP 500) +- A flag-only update such as `fp alerts update --disabled` (the documented + example) used to send a **partial** body to a server endpoint that does a **full + replace**, which failed with `HTTP 500`. Only a complete `--file` AlertInput worked. +- `alerts update` now does a **read-merge** (like `users update`): with override flags and + no `--file`, it fetches the current alert and re-sends it with just the named fields + changed — so `--disabled`, `--severity`, `--name`, etc. work on their own. This path now + needs `alerts:read` **and** `alerts:write`, and a missing alert returns a clean not-found + (exit 6) instead of a 500. The `--file` path is unchanged (a straight full replace). + +### Single `orgs` group (merged `org` + `orgs`) +- All tenant functionality now lives under one group, **`orgs`** — the separate singular + `org` group was removed. +- **`orgs list`** — list the orgs you belong to with your role (permission set) and permission + count in each; the active org is marked. `--json` adds `is_instance_admin` and a per-org + `active` flag. +- **`orgs switch [slug]`** — switch the active tenant. Pass a slug (`orgs switch acme`) for a + direct switch, or omit it (`orgs switch`) to choose from a list in a terminal — the current + org is the Enter-to-keep default, a sole org auto-selects, and a non-interactive run requires + a slug. Same access validation as `orgs use` (a non-existent/unauthorised slug is rejected). +- **`orgs use `** — set the default active tenant (unchanged behaviour, now under `orgs`). +- The interactive picker is shared between `login` and `orgs switch` (one implementation). + +### Removed the `logs` alias +- Dropped the `logs` command (it was a duplicate alias of `events`). Use `fp events`. + +### Consistent visual language across commands +- Unified the icon/colour vocabulary used everywhere: `◆` brand/identity (login header, + `whoami`, `orgs list`), `›` step, `✓` success (green), `○` neutral status (dim), `✗` error + (red). `whoami` when signed out is now a neutral `○ not signed in` line (not plain text), and + when signed in leads with the `◆ ` banner + a clean key/value + orgs table. +- Errors render as a clean one-line `✗ ` (with a `try '… -h'` nudge for usage errors) + instead of Typer's heavy red panel, so failures match the success/status lines. A doubled + "Did you mean …?" suggestion is collapsed. + +### Cleaner login output +- The sign-in flow now shows a single line — **`sending a 6-digit OTP to …`** (the + email highlighted) — and drops the dashboard-URL line and the "code is in your email / dev + SMTP" hint. It states only that an OTP was sent, never whether the address is valid. + +### Logout clears the whole session +- `logout` now clears the **active org**, email, and user id from `~/.fp/cli.json` + (not just the token) — previously the file kept `org`/`email`/`user_id`, so the CLI + "remembered" the last tenant after logout. `base_url`, the `insecure` preference, and the + machine-stable `anonymous_id` are kept. The next `login` therefore starts the org picker + fresh, with no remembered default. +- `logout` when you are **not** signed in is now a no-op that reports `○ already signed out` + (and `"already_signed_out": true` in `--json`) instead of falsely confirming a `✓ signed out`. + +### Nicer login/logout experience (presentation only) +- Restyled the sign-in flow — a compact `◆ fp · sign in` header, `›` step lines, a + cleaner numbered org picker, and a `✓ signed in as … · ` confirmation (and `✓ signed + out`). No behaviour change; all of it is stderr chrome, so `--json` stdout is unchanged. + +### Org validation at login / `orgs use` +- `login --org `, `FP_ORG`, and `orgs use ` now **validate the org + against the server before saving it** — it must exist and be accessible to you. This + fixes a hole where an instance admin could activate (and persist) a non-existent or + unauthorised slug (e.g. a typo like `--org fp`), which then broke every later + command. A member's own orgs are accepted with no extra round-trip; a non-member org is + verified via a cheap org-scoped probe (HTTP 200 → ok, otherwise rejected with + "Org '' does not exist or you do not have access to it"). + +### Login org picker +- `login` now shows an **interactive org picker** after your email is verified when you + belong to more than one org, so you choose the active tenant each login instead of + silently re-entering a previously-saved one. A still-valid saved org is offered as the + Enter-to-keep default (marked `(current)`). An explicit `--org`/`FP_ORG` still skips + the picker; single-org users still auto-select. In a non-interactive run (`--json` / piped + stdin) a still-valid saved org is reused, else login reports `needs_org_selection`. + +## 0.1.6 + +Full parity with the dashboard API, multi-tenant support, and exhaustive telemetry. + +### Multi-tenancy (correctness fix) +- The CLI is now org-aware. The active tenant is chosen **at login** (`login --org `) + and persisted; override per command with the global `--org` / `FP_ORG`, or change the + default with `orgs list` / `org use `. +- Every request now sends the `X-AgentEye-Org` header, so **multi-org accounts work** (previously + they were rejected). Permissions are resolved per org. +- `whoami` reports the active org, instance-admin status, per-org permissions, and all memberships + (the session model dropped the old flat `permissions` list). + +### New command groups +- **keys** — create/list/update/disable/regenerate API keys (secret shown once). +- **query** — saved SQL + ad-hoc runner (`query run`), schema introspection. +- **users**, **permission-sets**, **settings** — org administration. +- **alerts**, **incidents** — alert definitions and incident triage. +- **dashboards** (+ tiles), **agent** (`ask`, `compose-sql`, conversations) — JSON/streaming. +- **facets**, **errors**, **latency**, **score-keys**, **eval-aggregate** — discovery & analytics. +- `events` gains `--tool-name`, `--error-type`, `--errored`, `--order`, `--search`, `--search-exclude`. +- **schema** — emits the entire CLI surface as JSON for agents/tooling. + +### Safety & ergonomics +- Mutations confirm in a terminal but auto-skip under `--json` / non-TTY; `--yes`/`-y` to skip. +- Complex bodies accepted via `--file payload.json` (or `--file -` for stdin). +- One-time API-key secrets print to stdout (capturable) with the warning on stderr. + +### Telemetry +- Every command/subcommand and flag is tracked (the catalog is derived from the app, with an + anti-drift test that fails if anything goes untracked). Per-action events for all mutations. + Privacy unchanged: only static names/enums/coarse counts — never ids, emails, SQL, or values. + +### Notes +- Exit code `6` (resource not found) is now documented. +- New global option `--org`/`FP_ORG`. + +## 0.1.5 and earlier + +See the repository changelog. diff --git a/fp-cli/LICENSE b/fp-cli/LICENSE new file mode 100644 index 000000000..9802e634b --- /dev/null +++ b/fp-cli/LICENSE @@ -0,0 +1,42 @@ +MIT License + +Copyright (c) 2025 ExosphereHost Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Commons Clause License Condition v1.0 + +The Software is provided to you by the Licensor under the License, as defined +below, subject to the following condition. + +Without limiting other conditions in the License, the grant of rights under +the License will not include, and the License does not grant to you, the right +to Sell the Software. + +For purposes of the foregoing, "Sell" means practicing any or all of the +rights granted to you under the License to provide to third parties, for a +fee or other consideration (including without limitation fees for hosting or +consulting/support services related to the Software), a product or service +whose value derives, entirely or substantially, from the functionality of the +Software. Any license notice or attribution required by the License must also +include this Commons Clause License Condition notice. + +Software: failproofai +License: MIT +Licensor: ExosphereHost Inc diff --git a/fp-cli/README.md b/fp-cli/README.md new file mode 100644 index 000000000..87a32801f --- /dev/null +++ b/fp-cli/README.md @@ -0,0 +1,254 @@ +# FailproofAI Cloud CLI (`fp`) + +A command-line client for the **FailproofAI Cloud** API. It lets a developer — +or the coding agent working alongside them — authenticate and query agent +sessions, event logs, and evaluations from the terminal, with a `--json` flag on +every command for scripting. + +> **The package is `fp-cli`; the command is `fp`.** They differ because `fp` was +> already taken on PyPI. This is also distinct from `failproofai` on npm, which is +> the Enforcement CLI — that one runs inside the agent loop and decides what an +> agent may do; this one reads back what it did. + +## Install + +```bash +pipx install fp-cli # recommended (isolated) +# or: uv tool install fp-cli / pip install fp-cli +``` + +Then the command is `fp`: + +```bash +fp --version +fp help +``` + +For development in this repo: + +```bash +cd fp-cli +uv sync --extra dev +uv run fp --help +``` + +## Authentication + +The CLI talks to your dashboard (set `--base-url` or `FP_DASHBOARD_URL`) and logs in with +an emailed one-time code: + +```bash +fp login --email you@example.com +# enter the 6-digit code; the session is stored in +# ~/.failproofai/fpcli/cli-auth.json (mode 0600) +fp whoami +fp logout +``` + +Sessions expire (24h by default); re-run `fp login` when prompted. + +## Working across orgs (multi-tenant) + +Your account can belong to more than one org. The active org is chosen **at login** and saved: + +```bash +fp login --org acme # pick the tenant at login (saved for later commands) +fp orgs list # your orgs + your role in each (active one marked) +fp orgs switch globex # switch the active org (pass a slug…) +fp orgs switch # …or omit it to pick from a list (in a terminal) +fp orgs current # which org you're acting as right now (identity card) +fp orgs perms # your permissions in the active org (grouped by resource) +fp --org globex sessions # override for a single command +``` + +If you belong to exactly one org it's selected automatically. If you belong to several, you +pick at login (or with `orgs switch`); pass `--org` to choose non-interactively. A slug you +can't access (non-existent, or not yours) is rejected rather than saved. The active org is +sent as the `X-AgentEye-Org` header on every request, and your permissions are resolved per +org. (All tenant commands live under one group: `orgs list` / `orgs switch` / `orgs current` / +`orgs perms`.) + +## Commands + +**Query & observability** + +```text +fp sessions [--since 24h] [--status error] [--agent-id ] [--all] # runs: time/env/agent/session/status +fp evals [--score helpfulness:..0.5] [--all] # eval results + scores +fp evals --aggregate [--since 7d] # rolled-up eval health (status mix + per-metric score stats) +fp events --session-id [--event-type tool_use,tool_result] [--env prod] [--all] +fp errors [--since 24h] [--error-type TimeoutError] [--all] # list errored events +fp errors --aggregate [--since 7d] # error summary (count + sessions/agents/last seen) +fp list # discover dropdown values to filter on: +# envs · agents · event_types · score_filters · models · hooks · tools · error_types +``` + +**Manage your org** (each gated by the matching permission) + +```text +fp keys list|show|create|update|disable|regenerate # API keys (secret shown once) +fp users list|show|create|update|disable|enable +fp settings list|schema|set +fp alerts list|show|create|update|delete|test +fp issues list|count|show|ack|assign|resolve|comment-add|comment-list|comment-delete|subscribe|subscribers|unsubscribe|open +fp audits list|show|create|edit|delete|run|runs|findings|finding # scheduled audits +fp audits ack|assign|resolve|dismiss|mute|reopen # triage a finding +fp audits context-show|context-set|context-refresh # reference context +fp usage # org usage for the metering window +``` + +**Analytics & assistant** + +```text +fp query list|show|create|update|delete|run|schema # saved SQL + ad-hoc runner +fp agent health|models|chats|show|rename|delete|ask +# agent models → models available for --model (with the default marked) +# agent ask "…" → starts a NEW chat, answers + persists, prints the chat id +# agent ask --chat "…" → continue that chat (shows in the dashboard; 1st Q auto-titles) +# agent chats|show |rename --title …|delete → manage saved chats +fp version | help +``` + +**Mutations are non-interactive-safe.** Create/update/delete prompt for confirmation in a +terminal, but auto-skip the prompt under `--json` or when stdin isn't a TTY (so scripts/agents +never hang). Pass `--yes`/`-y` to skip it explicitly. Request bodies can be supplied with +`--file payload.json` (or `--file -` for stdin) on `alerts`, `settings`, and `users +create`/`update` — mutually exclusive with the discrete flags. (Saved-query SQL uses +`--sql @file.sql`.) + +Every command and subcommand has `--help` / `-h`; `fp -h` documents auth, exit codes, and +the global options. **Global options go before the command** (`fp --json events`, not +`fp events --json`). + +Add `--json` for machine-readable output, and `--fields` to project just the keys you need: + +```bash +fp --json events --session-id run-001 --all | jq '.events[].payload' +fp --json sessions --since 7d --fields session_id,status,scores +``` + +### Cloud-managed policies + +Three commands for the three jobs the dashboard splits across three pages — +`fp policies` writes a policy version, `fp fleet` decides which machines run it, +`fp guardrails summary` reports what it actually blocked. + +```bash +fp policies publish no-force-push ./rule.mjs # path, @path, a pipe, - or a paste +fp fleet deploy ci-runner-01 --add no-force-push +fp guardrails summary --since 24h +``` + +**A deploy REPLACES a machine's whole policy set.** The server takes the full +list and does not merge, so `fleet deploy` reads what the machine currently runs, +applies your `--add`/`--remove`, prints the complete resulting set, and writes +that. Use `--set` only when you mean "exactly these, drop the rest". + +```bash +fp fleet deploy ci-runner-01 \ + --add no-force-push \ # keeps its pinned version if already deployed + --add prod-guard@1:observe \ # id@version:effect + --remove old-rule +``` + +Three things worth knowing before you script it: + +* A bare `--add` of a policy the machine already runs keeps its **pinned + version**. Pass `id@version` to move it — a pin is usually deliberate. +* The endpoint has no lock. The CLI records the deployment generation it read and + **refuses** if the write does not land at exactly one higher, because that means + somebody deployed in between and a replace does not merge. +* The exit code separates your mistake from the server's answer. A malformed ref, + `--set` alongside `--add`/`--remove`, or no flags at all is **2**; a ref that + parses but names a policy that does not exist is **1**; an unknown machine is + **6**. Branch on those rather than on the message. + +`fp fleet diff` shows intent versus delivery: a machine can be deployed-to and +still enforcing an older set until it next polls. It refuses a machine id nobody +has reported under rather than rendering it as an empty fleet. + +These commands are **session-only** (`fp login`). They are absent from the +versioned API an API key authenticates against, so `--api-key` exits 2 with the +reason rather than failing at the request. + +## Configuration + +| Setting | Flag | Env var | Default | +|---|---|---|---| +| Dashboard URL | `--base-url` | `FP_DASHBOARD_URL` | `https://app.befailproof.ai` | +| Active org/tenant | `--org` | `FP_ORG` | chosen at login; saved in `~/.failproofai/fpcli/cli-auth.json` | +| Session token | `--token` | `FP_TOKEN` | from `~/.failproofai/fpcli/cli-auth.json` | +| API key (CI) | `--api-key` | `FP_API_KEY` | none; never written to disk | +| JSON output | `--json` | `FP_JSON` | off | +| Skip TLS verification | `--insecure` / `--secure` | `FP_INSECURE` | off (saved at login) | +| Disable usage telemetry | _(none)_ | `FP_ANALYTICS_DISABLED` (or `DO_NOT_TRACK`) | telemetry on | + +Precedence is **flag > environment variable > config file > built-in default**. A fresh +install points at the hosted product with no configuration; set `--base-url` or +`FP_DASHBOARD_URL` for a self-hosted or dev instance and it is saved after `login`. + +The session lives at `~/.failproofai/fpcli/cli-auth.json` (mode `0600`). The directory +is resolved as `FP_HOME` > `$FAILPROOFAI_HOME/fpcli` > `~/.failproofai/fpcli` — `FP_HOME` +names the CLI's own directory and is used as-is, so an existing export keeps addressing +the same place; `FAILPROOFAI_HOME` names the shared home root, so `fpcli/` is appended. + +The CLI only ever creates. It will bring `~/.failproofai` into existence on a machine +that has never run the Enforcement CLI, and leaves a populated one exactly as it found +it — nothing here removes or rewrites a path it does not own. The file is registered in +that home's layout (`src/hooks/fp-home.ts`) and classified `user-typed`, which is what +keeps a layout migration from dropping it. + +The Python SDK and the collector keep their own spool under `~/.agenteye`. That one is a +wire contract with the collector rather than a preference, so it did not move. + +> **Upgrading from a version that used `~/.fp/cli.json`?** Nothing to do — the old +> session is adopted on your next command, so you are not signed out. It is copied, not +> moved, so rolling back to an older `fp` still works; remove `~/.fp/` yourself once you +> are happy. + +For a dashboard with a self-signed or internal TLS certificate, add `--insecure` to skip +certificate verification (saved at login, so you set it once). This disables protection +against man-in-the-middle attacks — prefer a valid certificate outside internal/testing use. + +## Telemetry + +**Telemetry is currently disabled and this build sends nothing.** +`TELEMETRY_DISABLED` is `True` in `fp_cli/analytics_config.py`: when the analytics +host is unreachable the send path stalls every command for ~5s — the shutdown flush +is bounded, but the client build and first connect attempt are not — so it stays off +until that path is fully non-blocking. Nothing was removed; re-enabling is one +constant. + +The rest of this section describes what it collects **if** it is re-enabled, so you +can review it in advance rather than discover it in a release note: + +- which command ran (including its subcommand, e.g. `keys create`), success/exit + status and duration, the **names** of flags used, and a per-action event for + mutations (e.g. `api_key_created`, `query_run`) carrying only static names/enums + and coarse counts; +- **no data, URLs, tokens, emails, ids, SQL, key secrets, or query values are ever + sent**, and operators are identified only by an opaque id. + +`FP_ANALYTICS_DISABLED=1` (or the cross-tool `DO_NOT_TRACK=1`) opts out, and keeps +working as an opt-out if it is ever switched back on. See + for the full privacy details. + +## Exit codes + +| Code | Meaning | +|---|---| +| 0 | Success | +| 1 | Unexpected error (e.g. an unhandled server status) | +| 2 | Usage error (bad arguments) | +| 3 | Cannot reach the dashboard | +| 4 | Not logged in / session expired | +| 5 | Authenticated but missing permission | +| 6 | Resource not found | + +## Tests + +```bash +cd fp-cli +uv run --extra dev pytest +``` + diff --git a/fp-cli/fp_cli/__init__.py b/fp-cli/fp_cli/__init__.py new file mode 100644 index 000000000..9ab4bf1d3 --- /dev/null +++ b/fp-cli/fp_cli/__init__.py @@ -0,0 +1,10 @@ +"""FailproofAI Cloud CLI — a command-line client for the FailproofAI Cloud API. + +The query layer lives in :mod:`fp_cli.client` as pure functions that take a +``ClientContext`` and return plain dataclasses. They never print and never import +Typer/Rich, so a future MCP server can wrap them with zero duplication. +""" + +from ._version import __version__ + +__all__ = ["__version__"] diff --git a/fp-cli/fp_cli/__main__.py b/fp-cli/fp_cli/__main__.py new file mode 100644 index 000000000..29aaf10b3 --- /dev/null +++ b/fp-cli/fp_cli/__main__.py @@ -0,0 +1,4 @@ +from .app import main_entry + +if __name__ == "__main__": + main_entry() diff --git a/fp-cli/fp_cli/_click_compat.py b/fp-cli/fp_cli/_click_compat.py new file mode 100644 index 000000000..e8091a000 --- /dev/null +++ b/fp-cli/fp_cli/_click_compat.py @@ -0,0 +1,64 @@ +"""The Click that Typer is actually running. Import Click through here, never directly. + +Typer 0.26 vendored Click into ``typer._click`` and dropped its dependency on the pip +``click`` distribution. The vendored classes are *different class objects* from pip +Click's, so every place we hand Click an object, or ask Click about one, breaks when +the two disagree — and each break is silent, because the code still imports, still +compiles, and every happy path still passes: + +* ``ClickException`` — Typer's command runner catches only *its* Click's exception + class. Errors subclassing pip Click's escape **uncaught**: a typed failure that + should print ``✗ …`` and exit 5 exits **1 with an empty stderr**, and the + ``rich_format_error`` hook in ``app.py`` (reached only *after* that catch) never + runs. Same for the ``UsageError``/``BadParameter`` we raise by hand. +* ``Abort`` — ``typer.prompt`` raises its own Click's ``Abort`` on closed stdin, so a + pip-Click ``except click.Abort`` stops matching and the clean "no TTY, pass a slug" + usage error becomes a bare abort. +* Options — Typer 0.26+ has no ``Option`` class in its vendored Click *at all*: every + option in a Typer-built tree is a ``typer.core.TyperOption``, subclassing + ``Parameter`` directly. ``isinstance(param, click.Option)`` is then quietly always + False, which emptied the telemetry flag catalog (``analytics_registry``) while its + own anti-drift test stayed green — the test asked the same broken question. + +So: resolve the Click that Typer imported, and speak that one. +``tests/test_click_compat.py`` asserts the package never imports ``click`` directly +again, and that our errors are still the class Typer catches. +""" + +from __future__ import annotations + +try: + # typer >= 0.26. pip `click` may well still be installed — it is simply not the + # Click in play, so binding to it here would reintroduce the whole class of bug. + from typer._click import ClickException, Command, Parameter + from typer._click.exceptions import Abort, BadParameter, UsageError +except ImportError: # typer < 0.26 drives the pip `click` distribution directly + from click import ( # type: ignore[assignment] + Abort, + BadParameter, + ClickException, + Command, + Parameter, + UsageError, + ) + +__all__ = [ + "Abort", + "BadParameter", + "ClickException", + "Command", + "Parameter", + "UsageError", + "is_option", +] + + +def is_option(param: Parameter) -> bool: + """True if ``param`` is an option (``--flag``), not a positional argument. + + Class identity cannot answer this across both Clicks — the vendored one has no + ``Option`` class to test against — so read the ``param_type_name`` that Click sets + on every parameter (``"option"`` / ``"argument"``). That holds for pip Click's + ``Option`` and for ``TyperOption`` alike. + """ + return getattr(param, "param_type_name", None) == "option" diff --git a/fp-cli/fp_cli/_context.py b/fp-cli/fp_cli/_context.py new file mode 100644 index 000000000..083f5c0b7 --- /dev/null +++ b/fp-cli/fp_cli/_context.py @@ -0,0 +1,332 @@ +"""Shared command-layer state and helpers. + +Kept separate from ``app.py`` so command modules can import these without a +circular dependency (``app.py`` imports the command modules). +""" + +from __future__ import annotations + +import dataclasses +import math +import re +from dataclasses import dataclass +from typing import List, Optional, Sequence, Tuple + +from . import _click_compat as click # the Click Typer is running; see _click_compat +from . import config as cfgmod +from . import dates as _dates + +# `AuthMode` is DEFINED in `client.py`, not here, even though it is this layer that +# resolves it: the import runs `_context` -> `client` (below), and `client` needs the +# enum at runtime for its bearer-vs-cookie branch — defining it here would make that +# a cycle. Re-exported so `from ._context import AuthMode` reads naturally beside +# AppState, which is where most callers want it. +from .client import AuthMode, ClientContext +from .errors import AuthError, KeyModeUnsupportedError + + +@dataclass +class AppState: + json: bool + base_url: Optional[str] + token: Optional[str] + timeout: float + config: cfgmod.CliConfig + insecure: bool = False + org: Optional[str] = None # active tenant slug (flag > env > config) + # The EXPLICITLY-supplied tenant (global `--org` or `FP_ORG`), before + # the saved-config fallback. `login` uses this so a *saved* tenant never + # silently bypasses the interactive org picker — only an explicit choice does. + # Key mode reuses it as the ONLY org it will ever send (see `build_context`). + org_explicit: Optional[str] = None + # The bearer credential in key mode. NEVER written to `cli.json` — see + # `resolve_auth`. + api_key: Optional[str] = None + # Which credential this invocation carries. Defaults to NONE so an AppState + # built by another path (tests, embedders) is never silently treated as key + # mode; the transport reads it directly, and everything that is not API_KEY + # takes the cookie path. + auth_mode: AuthMode = AuthMode.NONE + + +def resolve_auth( + *, + api_key: Optional[str], + api_key_on_cli: bool, + token: Optional[str], + token_on_cli: bool, + saved_token: Optional[str], +) -> Tuple[AuthMode, Optional[str], Optional[str]]: + """Resolve the one credential this invocation uses → ``(mode, api_key, token)``. + + Precedence, highest first:: + + --api-key AND --token -> usage error, exit 2 (never guess) + --api-key -> key mode + --token -> session mode + FP_API_KEY -> key mode (a key env var beats a token env var) + FP_TOKEN -> session mode + cli.json session_token -> session mode + nothing -> AuthMode.NONE (require_auth then exits 4) + + ``*_on_cli`` distinguishes a flag from its env var, which the *values* cannot: + an explicit ``--token`` has to beat ``FP_API_KEY`` while + ``FP_API_KEY`` beats ``FP_TOKEN``. Click's + ``ctx.get_parameter_source`` is the only thing that knows the difference. + + ``--api-key ""`` (an unset CI variable spelled out) means "no override": the mode + stays KEY with an empty credential, so `require_auth` raises instead of quietly + acting as whichever human is logged in on this machine. That mirrors the + established ``--token ""`` rule exactly. + + The key is returned for this process only and is never persisted: a session token + expires in ~24h, which bounds the blast radius of a leaked `cli.json`; an API key + is valid until someone revokes it. There is also no honest revocation path from + here — `keys disable` needs `keys:disable`, which a scoped CI key will not hold — + so a "clear the saved key" command could not actually revoke anything. + """ + if api_key_on_cli and token_on_cli: + raise click.UsageError( + "--api-key and --token are mutually exclusive: --api-key authenticates as an " + "API key against /v1, --token as a signed-in user session. Pass exactly one." + ) + if api_key_on_cli: + return AuthMode.API_KEY, api_key, None + if token_on_cli: + return AuthMode.SESSION, None, token + # Neither flag was given: whatever Click resolved came from the environment. + # (Click treats an empty env var as unset, so `FP_API_KEY=""` falls + # through to the next rung rather than becoming an empty-credential key mode.) + if api_key is not None: + return AuthMode.API_KEY, api_key, None + if token is not None: + return AuthMode.SESSION, None, token + if saved_token: + return AuthMode.SESSION, None, saved_token + return AuthMode.NONE, None, None + + +def deny_in_key_mode(state: "AppState", command: str, reason: str) -> None: + """Fail ``command`` with exit 2 when this invocation carries an API key. + + Called as the FIRST statement of every command an API key cannot perform, so the + CLI never opens a connection it already knows will fail — a 401/403 from the + server would be a much worse explanation than the real one. + """ + if state.auth_mode is AuthMode.API_KEY: + raise KeyModeUnsupportedError( + f"`fp {command}` does not work with an API key — {reason}.", + hint="drop --api-key / FP_API_KEY and sign in with fp login", + ) + + +def resolved_base_url(state: AppState) -> str: + """The effective dashboard URL. + + The app callback already resolves flag/env > saved config > the public + default (`config.DEFAULT_BASE_URL`), so `state.base_url` is normally set. + This falls back to the same default for any AppState built by another path + (tests, embedders), so the CLI always has a URL to talk to. + """ + return state.base_url or cfgmod.DEFAULT_BASE_URL + + +def _org_header(state: AppState) -> Optional[str]: + """The tenant slug to send as ``X-AgentEye-Org``. + + In KEY mode only an EXPLICIT ``--org`` / ``FP_ORG`` is ever sent. The saved + `cli.json` org belongs to whichever human logged in on this machine and has no + bearing on which org a CI key was minted for; sending it would silently ask for + another tenant's data and get a 403 that reads like a missing permission. + + (The reverse trap is real too and `whoami` is the pre-flight for it: an + instance-scoped key with no `--org` resolves server-side to the DEFAULT org and + answers with that org's data, no error anywhere.) + """ + if state.auth_mode is AuthMode.API_KEY: + return state.org_explicit + return state.org + + +def build_context(state: AppState) -> ClientContext: + return ClientContext( + base_url=resolved_base_url(state), + token=state.token, + timeout=state.timeout, + verify=not state.insecure, + org=_org_header(state), + api_key=state.api_key, + auth_mode=state.auth_mode, + ) + + +def require_auth(state: AppState) -> ClientContext: + """Return a client context, or raise if no URL is set / not authenticated.""" + base = resolved_base_url(state) # the URL is the prerequisite — check it first + if state.auth_mode is AuthMode.API_KEY: + # `--api-key ""` lands here: key mode, no credential. It must NOT fall back + # to a saved cookie session (see `resolve_auth`), so this is an auth failure. + if not state.api_key: + raise AuthError( + "No API key supplied. Pass --api-key or set FP_API_KEY." + ) + # No local expiry check: an API key carries no expiry the CLI can see, and the + # server is the only authority on whether it is still live. + return ClientContext( + base_url=base, + api_key=state.api_key, + auth_mode=AuthMode.API_KEY, + timeout=state.timeout, + verify=not state.insecure, + org=_org_header(state), + ) + if not state.token: + # A pre-move file with no usable session in it (expired, or only ever + # held a base_url) reaches here after adoption declined to carry + # anything. Name it, so the upgrade is not blamed for the logout. + if cfgmod.legacy_install_detected(): + raise AuthError( + "Not logged in. Run fp login.\n" + f"The config moved to {cfgmod.config_path()}; " + f"{cfgmod.legacy_config_path()} held no usable session to carry " + "over. It is left in place — remove it when convenient." + ) + raise AuthError("Not logged in. Run fp login.") + # Only enforce local expiry when the token came from the stored config; an + # explicit --token / env override has no known expiry, so trust it. + if state.token == state.config.session_token and cfgmod.is_expired(state.config): + raise AuthError("Session expired. Run fp login.") + return ClientContext( + base_url=base, + token=state.token, + timeout=state.timeout, + verify=not state.insecure, + org=_org_header(state), + ) + + +def resolve_dates( + since: Optional[str], ts_from: Optional[str], ts_to: Optional[str] +) -> Tuple[Optional[str], Optional[str]]: + try: + return _dates.resolve_range(since, ts_from, ts_to) + except ValueError as exc: + raise click.BadParameter(str(exc)) + + +def resolve_fields(raw: Optional[str], model_cls: type) -> Optional[List[str]]: + """Parse a ``--fields`` CSV into a validated list of model field names (or None). + + Field names must match the dataclass (and thus the ``--json``) keys of the + model; unknown names raise a usage error listing the valid set. + """ + if not raw: + return None + valid = [f.name for f in dataclasses.fields(model_cls)] + chosen = [f.strip() for f in raw.split(",") if f.strip()] + unknown = [f for f in chosen if f not in valid] + if unknown: + raise click.BadParameter( + f"unknown field(s): {', '.join(unknown)}. Valid fields: {', '.join(valid)}" + ) + return chosen + + +def collect_multi(values: Optional[Sequence[str]]) -> Optional[List[str]]: + """Normalize a repeatable + comma-separated CLI option into one flat, de-duplicated list. + + The single reusable helper behind every multi-value filter. Typer hands us a list with + one entry per repeated flag (``--env prod --env staging`` → ``["prod", "staging"]``), and + each entry may itself be a comma-separated group (``--env prod,staging`` → ``["prod,staging"]``). + This splits every entry on commas, trims surrounding whitespace (so ``--env "prod, staging"`` + works), drops empties (so a trailing comma ``--env prod,`` adds nothing), and de-duplicates + while preserving first-seen order. Returns ``None`` when nothing usable remains, so an unset + or blank option stays ``None`` and the client drops the param (unchanged single-value path). + + A single value still yields the obvious one-item list (``--env prod`` → ``["prod"]``), which + the client serializes back to a bare ``environment=prod`` — fully backward compatible. + """ + if not values: + return None + out: List[str] = [] + seen: set = set() + for raw in values: + for part in str(raw).split(","): + v = part.strip() + if v and v not in seen: + seen.add(v) + out.append(v) + return out or None + + +def validate_choice( + value: Optional[str], allowed: Sequence[str], *, flag: str +) -> Optional[str]: + """Return ``value`` if it's None or one of ``allowed``; else a usage error (exit 2). + + Keeps enum-style flags (``--status``) failing fast client-side with a clear + message, consistent with ``--order``/``--source``/``--kind`` — rather than the + server's opaque HTTP 400 (exit 1). + """ + if value is None or value in allowed: + return value + raise click.BadParameter( + f"'{value}' is not valid for {flag}. Choose one of: {', '.join(allowed)}.", + param_hint=flag, + ) + + +def validate_limit(limit: Optional[int], *, flag: str = "--limit") -> None: + """Reject a non-positive row limit up front with a clean usage error (exit 2), rather + than passing ``0``/negative through to the server, which silently defaults/clamps it to + a confusing result. Mirrors ``validate_choice`` / ``validate_score_filters``.""" + if limit is not None and limit <= 0: + raise click.BadParameter("must be a positive integer (at least 1).", param_hint=flag) + + +def validate_score_filters(values: Optional[Sequence[str]]) -> None: + """Validate ``--score`` values are ``KEY:MIN..MAX`` (either bound optional). + + Without this a malformed value (no ``:`` or no ``..``) is silently sent and + dropped server-side, returning the UNFILTERED set — a silent footgun. Raises a + usage error (exit 2) on a bad value. Accepts e.g. ``helpfulness:0.5..0.8``, + ``x:..0.5``, ``y:0.9..``. + """ + for v in values or []: + key, sep, rng = v.partition(":") + ok = bool(sep) and bool(key.strip()) and ".." in rng + if ok: + lo, _, hi = rng.partition("..") + # Both bounds empty (`helpfulness:..`) is meaningless: the server silently + # drops it and returns the UNFILTERED set, so reject it client-side too. + if lo == "" and hi == "": + ok = False + for bound in (lo, hi): + if bound != "": + try: + # `float("nan")`/`float("inf")` succeed but the server silently + # drops the filter — reject non-finite bounds too. + if not math.isfinite(float(bound)): + ok = False + break + except ValueError: + ok = False + break + if not ok: + raise click.BadParameter( + f"'{v}' is not a valid score filter. Use KEY:MIN..MAX (either bound " + "optional), e.g. helpfulness:0.5..0.8, tool_efficiency:..0.3, factuality:0.9..", + param_hint="--score", + ) + + +# Appended to every subcommand's --help so an agent that jumps straight to +# `fp -h` still learns the argument order + where the global options go. +GLOBALS_EPILOG = ( + "Argument order: `fp [GLOBAL OPTIONS] [] [ARGS] [OPTIONS]`. " + "**Global** options come *before* the command — `--json`, `--base-url`, `--token`, " + "`--api-key`, `--insecure`/`--secure`, `--timeout`, `--quiet`, `--no-color`. A command's (or " + "subcommand's) own options come *after* it. " + "e.g. `fp --json keys create ci-bot --permission-set read-only` — `--json` is global, " + "`keys` the command, `create` the subcommand, `--permission-set` its option." +) diff --git a/fp-cli/fp_cli/_version.py b/fp-cli/fp_cli/_version.py new file mode 100644 index 000000000..6852ddf8f --- /dev/null +++ b/fp-cli/fp_cli/_version.py @@ -0,0 +1 @@ +__version__ = "0.1.22" diff --git a/fp-cli/fp_cli/analytics.py b/fp-cli/fp_cli/analytics.py new file mode 100644 index 000000000..3a3358597 --- /dev/null +++ b/fp-cli/fp_cli/analytics.py @@ -0,0 +1,422 @@ +"""PostHog telemetry for the CLI — a thin, fail-safe wrapper. + +Mirrors the dashboard's ``lib/analytics.ts``: import-safe, a no-op until +``init_analytics`` runs, identifies operators by an opaque id only (never email), +and tags every event with ``product`` super-properties. Adapted for a short-lived +process: events are flushed on exit within a hard time bound, and **every** public +function swallows all exceptions so telemetry can never slow or break a command. + +Privacy: the generic ``command_executed`` event records only static names (command, +subcommand, flag NAMES) plus coarse booleans/counts and one closed enum +(``auth_mode``). Argument *values* — dashboard URL, session token, API key, email, +session ids, score expressions, file paths — are never sent, in any form: not the +value, not its length, not a prefix. +""" + +from __future__ import annotations + +import logging +import platform +import threading +import uuid +from typing import Any, Dict, FrozenSet, List, Optional, Tuple + +from . import analytics_config as acfg +from . import config as cfgmod +from ._version import __version__ + +# Single client + per-invocation context (the dashboard keeps one posthog-js instance). +# The client is built LAZILY (see _ensure_client) on the first actual send, so a normal +# command never pays the posthog import + client construction on its startup path. +_client: Any = None +_distinct_id: Optional[str] = None +_command: Optional[str] = None +_json_output: bool = False +_auth_mode: str = "none" +_force_anonymous: bool = False +_pending_conf: Any = None +_init_done: bool = False + +# Hard cap on how long shutdown may block the CLI waiting on the network. +_FLUSH_TIMEOUT_SECS = 1.5 + +# Recognised command / flag names. These are the STATIC FALLBACK used only if the +# derived registry (analytics_registry, introspected from the live Typer app) cannot +# be built. The generic event emits ONLY known static strings, so customer data (urls, +# tokens, ids, queries, paths) can never leak through it. The anti-drift test asserts +# the *derived* catalog stays exhaustive, which is the real guarantee of completeness. +_KNOWN_COMMANDS = frozenset( + { + "login", "logout", "whoami", "orgs", "events", + "sessions", "evals", "version", "help", + "list", "errors", "keys", "query", + "users", "settings", "alerts", "audits", "issues", "agent", + } +) +# group -> its leaf subcommand names (static fallback). +_STATIC_LEAVES: Dict[str, FrozenSet[str]] = { + "list": frozenset({"envs", "agents", "event_types", "score_filters", "models", + "hooks", "tools", "error_types"}), + "orgs": frozenset({"list", "switch", "current", "perms"}), + "keys": frozenset({"list", "show", "create", "update", "disable", "regenerate"}), + "query": frozenset({"list", "show", "create", "update", "delete", "run", "schema"}), + "users": frozenset({"list", "show", "create", "update", "disable", "enable"}), + "settings": frozenset({"list", "schema", "set"}), + "alerts": frozenset({"list", "show", "create", "update", "delete", "test"}), + "audits": frozenset({"list", "show", "create", "edit", "delete", "run", "runs", "findings", + "finding", "ack", "mute", "dismiss", "resolve", "reopen", "assign"}), + "issues": frozenset({"list", "count", "show", "ack", "assign", "resolve", "comment-list", + "comment-add", "comment-delete", "subscribers", "subscribe", "unsubscribe", "open"}), + "agent": frozenset({"health", "models", "chats", "show", "rename", "delete", "ask"}), +} +# Global options that consume a following token (static fallback for value-skipping). +_STATIC_VALUE_FLAGS = frozenset( + {"--base-url", "--org", "--token", "--api-key", "--timeout", "--email", "-e"} +) + +# Any flag token (long or short) -> its canonical long name (static fallback). +_FLAG_ALIASES: Dict[str, str] = { + "--json": "--json", + "--base-url": "--base-url", + "--org": "--org", + "--token": "--token", + "--api-key": "--api-key", + "--timeout": "--timeout", + "--no-color": "--no-color", + "--quiet": "--quiet", "-q": "--quiet", + "--insecure": "--insecure", "--secure": "--secure", + "--version": "--version", + "--email": "--email", "-e": "--email", + "--environment": "--environment", "--env": "--environment", + "--agent-id": "--agent-id", + "--status": "--status", + "--score": "--score", + "--since": "--since", "--from": "--from", "--to": "--to", + "--limit": "--limit", "-n": "--limit", + "--cursor": "--cursor", + "--all": "--all", + "--page-size": "--page-size", + "--fields": "--fields", + "--arg": "--arg", "--param": "--param", "--force": "--force", + "--session-id": "--session-id", + "--event-type": "--event-type", + "--audit": "--audit", "--run-id": "--run-id", "--offset": "--offset", + "--reason": "--reason", "--to": "--to", "--show-id": "--show-id", + "--scope": "--scope", "--sensitivity": "--sensitivity", "--top-k": "--top-k", + "--window-mode": "--window-mode", "--channels": "--channels", + "--latest-per-session": "--latest-per-session", + "--events-limit": "--events-limit", + "--no-events": "--no-events", "--no-eval": "--no-eval", + "--output": "--output", "-o": "--output", + "--source": "--source", + "--help": "--help", "-h": "--help", +} + + +def _catalog() -> "tuple": + """``(known_commands, leaf_registry, flag_aliases, value_flags)``. + + Prefers the live derived catalog; falls back to the static tables if introspection + fails (telemetry is best-effort and must never raise into a command). + """ + try: + from . import analytics_registry as reg + + known, leaves, flags, value_flags = reg.build() + if known and flags: + return known, leaves, flags, value_flags + except Exception: + pass + return _KNOWN_COMMANDS, _STATIC_LEAVES, _FLAG_ALIASES, _STATIC_VALUE_FLAGS + +# The CLOSED set of auth modes we will emit. Mirrors `client.AuthMode`, but stated as +# a literal allowlist so an unexpected value degrades to "none" rather than being +# forwarded verbatim — this property must never become a channel for anything but +# these three words. +_AUTH_MODES: Dict[str, str] = {"session": "session", "api_key": "api_key", "none": "none"} + +# Exit code -> coarse error class (derived from errors.py / Click conventions). +_EXIT_CATEGORY: Dict[int, Optional[str]] = { + 0: None, # success + 2: "usage", + 3: "network", + 4: "auth", + 5: "forbidden", + 6: "not_found", +} + + +def init_analytics(conf: cfgmod.CliConfig, *, force_anonymous: bool = False) -> None: + """Record config for a LAZILY-constructed PostHog client (resolve + build on first send). + + Deferring the posthog import + client construction keeps them off every command's startup + path; an invocation that never emits an event (the common case, and every opted-out user) + pays nothing. :func:`_ensure_client` does the real work the first time a send is attempted. + + ``force_anonymous`` is set in API-key mode. :func:`_resolve_distinct_id` reads the + SAVED config, so a machine where a human is logged in would attribute a CI key's + commands to that person — a wrong identity, silently, forever. + """ + global _pending_conf, _init_done, _client, _distinct_id, _force_anonymous + _pending_conf = conf + _init_done = False + _client = None + _distinct_id = None + _force_anonymous = bool(force_anonymous) + + +def _ensure_client() -> None: + """Build the PostHog client on first use (idempotent). Any failure leaves telemetry off.""" + global _client, _distinct_id, _init_done + if _init_done: + return + _init_done = True + conf = _pending_conf + if conf is None: + return + settings = acfg.resolve_config() + if not settings.enabled: + return + try: + from posthog import Posthog + + _distinct_id = ( + _ensure_anonymous_id(conf) if _force_anonymous else _resolve_distinct_id(conf) + ) + _client = Posthog( + settings.api_key, + host=settings.host, + flush_at=1, # a CLI fires few events then exits — send promptly + max_retries=1, # don't retry-storm a blocked/slow network + timeout=3, # short per-request timeout + disable_geoip=True, # don't resolve the caller's IP to a location + super_properties=_super_properties(), + ) + # The CLI owns stderr; keep posthog's own logger from printing onto it. + logging.getLogger("posthog").setLevel(logging.CRITICAL) + except Exception: + _client = None + + +def note_command( + command: Optional[str], json_output: bool, auth_mode: Any = None +) -> None: + """Record (from the main callback) which command ran, whether ``--json`` is set, and + which credential it used. + + Authoritative — taken from the parsed Typer context, not re-parsed from argv. Uses the + cheap STATIC command set (no Typer→Click tree build on the startup path). Safe to call + when telemetry is disabled (it only sets module state). + + ``auth_mode`` is an ``AuthMode`` (a ``str`` enum we authored, so the property is a + closed set: ``session`` | ``api_key`` | ``none``). Never the key, its length, or a + prefix — the mode is the whole signal, and it is what makes "is anyone actually + running this in CI?" answerable. + """ + global _command, _json_output, _auth_mode + _command = command if command in _KNOWN_COMMANDS else None + _json_output = bool(json_output) + _auth_mode = _AUTH_MODES.get(str(getattr(auth_mode, "value", auth_mode)), "none") + + +def capture(event: str, properties: Optional[Dict[str, Any]] = None) -> None: + """Send one event. No-op until initialised; never raises.""" + _ensure_client() + if _client is None: + return + try: + _client.capture(_distinct_id, event, properties=properties or {}) + except Exception: + pass + + +def capture_command(exit_code: int, duration_ms: int, argv: List[str]) -> None: + """Emit the generic ``command_executed`` event with an allowlisted payload. + + ``command``/``subcommand`` come from resolving argv against the known command + tree, so every group's leaf (``keys create``, ``orgs switch``, …) is distinguished — + not just the top-level command. Both are static names from the catalog; values never leak. + """ + _ensure_client() + if _client is None: + return + group, leaf = _resolve_command_path(argv) + command = _command if _command is not None else group + subcommand = leaf if (command is not None and command == group) else None + capture( + "command_executed", + { + "command": command, + "subcommand": subcommand, + "success": exit_code == 0, + "exit_code": exit_code, + "error_category": _EXIT_CATEGORY.get(exit_code, "error"), + "duration_ms": duration_ms, + "flags": _sanitize_flags(argv), + "json_output": _json_output, + "auth_mode": _auth_mode, + }, + ) + + +def identify(user_id: Optional[str]) -> None: + """Link this machine's prior anonymous activity to the operator id (called on login). + + Mirrors the dashboard's ``posthog.identify(id)`` by the opaque operator id only. + No-op when telemetry is off or there is nothing to link. + """ + _ensure_client() + if _client is None or not user_id: + return + try: + conf = cfgmod.load_config() + if conf.anonymous_id and conf.anonymous_id != user_id: + _client.alias(previous_id=conf.anonymous_id, distinct_id=user_id) + except Exception: + pass + + +def reset() -> None: + """Rotate the local anonymous id on logout so later anon events aren't tied to the user. + + posthog-python has no client-side reset; this is local state only (the analog of + the dashboard's ``posthog.reset()``). Runs **regardless** of whether telemetry is + currently enabled: ``anonymous_id`` is persistent config state and the opt-out + flag can be toggled between runs, so the unlink-on-logout invariant must hold even + when this invocation has telemetry off (otherwise a later re-enabled run could + reuse an id still aliased to the operator who just logged out). + """ + try: + conf = cfgmod.load_config() + conf.anonymous_id = uuid.uuid4().hex + cfgmod.save_config(conf) + except Exception: + pass + + +def shutdown() -> None: + """Flush pending events, bounded so telemetry never stalls the CLI on exit. + + Idempotent. The flush runs on a daemon thread joined for at most + ``_FLUSH_TIMEOUT_SECS``; if the network is slow/blocked the thread is abandoned + (it dies with the process) and the event is dropped — telemetry is best-effort. + + TODO(telemetry-nonblocking): this bounds only the *final* flush. The client build + and first ``capture()`` connect (in ``_ensure_client`` / ``capture_command``) are + NOT bounded, so a blocked PostHog host stalls the CLI ~5s/command before this runs. + Telemetry is currently disabled via ``analytics_config.TELEMETRY_DISABLED``; before + re-enabling, move the whole capture+flush onto this bounded daemon thread (or set an + aggressive connect timeout on the client) so no command can ever block on it. + """ + global _client + client = _client + _client = None # a second call is a no-op + if client is None: + return + + def _drain() -> None: + try: + client.flush() + except Exception: + pass + try: + client.shutdown() + except Exception: + pass + + thread = threading.Thread(target=_drain, name="posthog-flush", daemon=True) + thread.start() + thread.join(_FLUSH_TIMEOUT_SECS) + + +# --- internals ------------------------------------------------------------------- + + +def _super_properties() -> Dict[str, str]: + """Tags merged into every event (the analog of ``posthog.register``).""" + return { + "product": acfg.PRODUCT, + "cli_version": __version__, + "os": platform.system(), # coarse: "Linux" / "Darwin" / "Windows" + "python_version": platform.python_version(), + } + + +def _resolve_distinct_id(conf: cfgmod.CliConfig) -> str: + """Opaque operator id when logged in, else a stable per-machine anonymous id. + + ``user_id`` is used only alongside a stored session token, so a logged-out machine + falls back to its anonymous id (which :func:`reset` rotates on logout). + """ + if conf.user_id and conf.session_token: + return conf.user_id + return _ensure_anonymous_id(conf) + + +def _ensure_anonymous_id(conf: cfgmod.CliConfig) -> str: + """Return the persisted anonymous id, generating and saving one on first use.""" + if conf.anonymous_id: + return conf.anonymous_id + anon = uuid.uuid4().hex + conf.anonymous_id = anon + try: + cfgmod.save_config(conf) + except Exception: + pass # fall back to an in-memory id for this run + return anon + + +def _resolve_command_path(argv: List[str]) -> Tuple[Optional[str], Optional[str]]: + """Resolve ``(group, leaf)`` from argv against the known command tree. + + Walks tokens, skipping global/option flags (and the value of a value-taking option), + to find the first known top-level command (``group``) and then, if that group has + subcommands, the first matching leaf. Only ever returns static names from the + catalog, so argument values can never be emitted. Generalises the old + ``session``-only detection to every group. + """ + known, leaves, _flags, value_flags = _catalog() + group: Optional[str] = None + leaf: Optional[str] = None + i = 0 + while i < len(argv): + tok = argv[i] + if tok.startswith("-"): + name = tok.split("=", 1)[0] + if "=" not in tok and name in value_flags: + i += 2 # skip the option's value token + continue + i += 1 + continue + if group is None: + if tok in known: + group = tok + i += 1 + continue + if leaf is None and tok in leaves.get(group, frozenset()): + leaf = tok + break + i += 1 + return group, leaf + + +def _detect_subcommand(argv: List[str]) -> Optional[str]: + """Back-compat shim: the leaf subcommand for the resolved command (or None).""" + return _resolve_command_path(argv)[1] + + +def _sanitize_flags(argv: List[str]) -> List[str]: + """Flag NAMES present in argv, intersected with the known set — never values. + + ``--opt=value`` is split so the value is dropped, and any unknown token (including + a value that happens to start with ``-``) is discarded. + """ + aliases = _catalog()[2] + seen: List[str] = [] + for tok in argv: + if not tok.startswith("-"): + continue + canonical = aliases.get(tok.split("=", 1)[0]) + if canonical and canonical not in seen: + seen.append(canonical) + return seen diff --git a/fp-cli/fp_cli/analytics_config.py b/fp-cli/fp_cli/analytics_config.py new file mode 100644 index 000000000..d07747e88 --- /dev/null +++ b/fp-cli/fp_cli/analytics_config.py @@ -0,0 +1,77 @@ +"""Resolve whether CLI telemetry runs, and with what PostHog credentials. + +Mirrors the dashboard's ``lib/posthog-config.ts``: a public, write-only project key +shipped in the package, an opt-out env var, and a dev/prod gate — resolved at +invocation time. This module is intentionally pure (no ``posthog`` import) so it is +cheap and safe to import and easy to unit-test. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + +# Public, write-only project key — the SAME PostHog project the dashboard uses +# (dashboard/lib/posthog-config.ts). Safe to ship in the package: it can only +# ingest events, not read them. +POSTHOG_KEY = "phc_Ac1Ww1GqKc0z1SyrRWbmatEeQdlOQIsDEEdP8l8JRgX" + +# Direct ingest host. The dashboard posts to its own ``/ingest`` path and +# reverse-proxies to this; a CLI has no first-party origin to proxy through, so it +# talks to PostHog directly. +POSTHOG_HOST = "https://us.i.posthog.com" + +# Tags every event so Cloud CLI data separates from the co-tenant "failproofai" +# (Enforcement) CLI in the shared Failproof AI project. REQUIRED — the analog of +# ``posthog.register({ product })``. +# +# This was ``"agenteye"`` before the rename. The value change splits the series in +# PostHog: saved insights filtered on ``product = 'agenteye'`` keep the historical +# rows and see nothing new. That is deliberate and the discontinuity is harmless +# here because ``TELEMETRY_DISABLED`` has been ``True`` since well before the +# rename, so no events were flowing across the boundary in either direction. +PRODUCT = "fp-cli" + +# Master kill switch — telemetry is DISABLED for now (kept in the codebase, not removed). +# When the PostHog host is unreachable the send path blocks the CLI ~5s/command: the +# ``analytics.shutdown`` flush is bounded to 1.5s, but the client-build + first ``capture`` +# connect attempt is not, so a blocked network stalls every command (even offline ones like +# ``version``). Until that path is made fully non-blocking (see the TODO in +# ``analytics.shutdown``), telemetry stays off. Flip this to ``False`` to re-enable; nothing +# else was removed, so re-enabling needs no other change. +TELEMETRY_DISABLED = True + +_TRUTHY = {"1", "true", "yes"} + + +def _truthy_env(name: str) -> bool: + """Match the dashboard's ``isDisabled()`` parse: trim + lowercase, 1/true/yes.""" + return (os.environ.get(name) or "").strip().lower() in _TRUTHY + + +@dataclass +class AnalyticsConfig: + enabled: bool + api_key: str + host: str + + +def is_disabled() -> bool: + """Opt-out via our own ``FP_ANALYTICS_DISABLED`` or the cross-tool ``DO_NOT_TRACK``.""" + return _truthy_env("FP_ANALYTICS_DISABLED") or _truthy_env("DO_NOT_TRACK") + + +def is_dev_or_test() -> bool: + """The CLI analog of the dashboard's ``NODE_ENV === 'production'`` gate. + + Keeps our own test runs and source checkouts out of the shared project. Customer + CI usage is intentionally **not** excluded here — that signal is wanted; CI users + who want out can set ``DO_NOT_TRACK``. + """ + return "PYTEST_CURRENT_TEST" in os.environ or _truthy_env("FP_CLI_DEV") + + +def resolve_config() -> AnalyticsConfig: + """Resolve telemetry settings now (like the dashboard's request-time check).""" + enabled = not TELEMETRY_DISABLED and not is_disabled() and not is_dev_or_test() + return AnalyticsConfig(enabled=enabled, api_key=POSTHOG_KEY, host=POSTHOG_HOST) diff --git a/fp-cli/fp_cli/analytics_registry.py b/fp-cli/fp_cli/analytics_registry.py new file mode 100644 index 000000000..e23a61f9f --- /dev/null +++ b/fp-cli/fp_cli/analytics_registry.py @@ -0,0 +1,83 @@ +"""Derive the telemetry command/flag catalog from the assembled Typer app. + +Hand-maintained allowlists drift: a new command or flag silently goes untracked +(lost signal) or, worse, leaks a value. Instead we walk the real Click command +tree once and build the catalog from it, so coverage is automatic. The anti-drift +test (`tests/test_telemetry_completeness.py`) asserts this stays exhaustive. + +Built lazily and cached — the app module imports ``analytics``, so we cannot import +the app at module load without a cycle. Everything here is read-only introspection. +""" + +from __future__ import annotations + +from functools import lru_cache +from typing import Dict, FrozenSet, List, Set, Tuple + +from . import _click_compat as click # the Click Typer is running; see _click_compat + + +def _option_tokens(opt: click.Parameter) -> List[str]: + return list(opt.opts) + list(opt.secondary_opts) + + +def _canonical(tokens: List[str]) -> str: + longs = [t for t in tokens if t.startswith("--")] + return longs[0] if longs else tokens[0] + + +def _takes_value(opt: click.Parameter) -> bool: + """True for options that consume a following token (so we can skip the value).""" + return not getattr(opt, "is_flag", False) and not getattr(opt, "count", False) + + +def _walk( + cmd: click.Command, + prefix: Tuple[str, ...], + known: Set[str], + leaves: Dict[str, Set[str]], + flags: Dict[str, str], + value_flags: Set[str], +) -> None: + for param in getattr(cmd, "params", []): + # `is_option`, not `isinstance(param, Option)`: Typer's vendored Click has no + # Option class, so an identity test silently matches nothing and this catalog + # comes back empty. See `_click_compat`. + if click.is_option(param): + tokens = _option_tokens(param) + if not tokens: + continue + canon = _canonical(tokens) + for tok in tokens: + flags[tok] = canon + if _takes_value(param): + value_flags.update(tokens) + subcommands = getattr(cmd, "commands", None) + if subcommands: + for name, sub in subcommands.items(): + if not prefix: + known.add(name) # top-level command or group name + else: + leaves.setdefault(prefix[0], set()).add(name) # nested leaf + _walk(sub, prefix + (name,), known, leaves, flags, value_flags) + + +@lru_cache(maxsize=1) +def build() -> Tuple[FrozenSet[str], Dict[str, FrozenSet[str]], Dict[str, str], FrozenSet[str]]: + """Return ``(known_commands, leaf_registry, flag_aliases, value_flags)``.""" + from typer.main import get_command + + from .app import app # lazy: avoids the app <-> analytics import cycle + + cli = get_command(app) + known: Set[str] = set() + leaves: Dict[str, Set[str]] = {} + flags: Dict[str, str] = {} + value_flags: Set[str] = set() + _walk(cli, (), known, leaves, flags, value_flags) + return ( + frozenset(known), + {group: frozenset(subs) for group, subs in leaves.items()}, + dict(flags), + frozenset(value_flags), + ) diff --git a/fp-cli/fp_cli/app.py b/fp-cli/fp_cli/app.py new file mode 100644 index 000000000..b603d4648 --- /dev/null +++ b/fp-cli/fp_cli/app.py @@ -0,0 +1,429 @@ +"""Typer application: global options + command registration.""" + +from __future__ import annotations + +import sys +import time +from typing import Optional + +import typer + +from . import _click_compat as click # the Click Typer is running; see _click_compat +from . import _context +from . import analytics +from . import config as cfgmod +from . import output +from . import orgs as orgsmod +from ._context import AppState +from ._version import __version__ +from .commands import ( + agent_cmds, + alerts_cmds, + audits_cmds, + auth_cmds, + errors_cmds, + evals_cmds, + events_cmds, + fleet_cmds, + guardrails_cmds, + incidents_cmds, + keys_cmds, + list_cmds, + orgs_cmds, + policies_cmds, + queries_cmds, + sessions_cmds, + settings_cmds, + usage_cmds, + users_cmds, +) + +_HELP = """\ +Query FailproofAI Cloud — **sessions, events, and evaluation results** — from your terminal. + +Every data command accepts `--json` for stable, machine-readable output: data on **stdout**, and +on failure a `{"error": …, "exit_code": …}` object on **stdout** too; human status/progress goes to +**stderr**. So it is safe to script and easy for AI agents to parse. + +**Getting started** + +* `fp --base-url https://cloud.example.com login` — email a one-time code, then paste it +* `fp whoami` — confirm who you are and your permissions +* `fp --json sessions --since 24h` — run a query (results are newest-first) + +**Global options come before the command.** `fp --json --base-url URL events` is correct; +`fp events --json` is not. The globals are `--json`, `--base-url`, `--org`, `--token`, +`--api-key`, `--insecure`/`--secure`, `--timeout`, `--quiet`, and `--no-color`. + +**In CI, authenticate with an API key** — `--api-key ` or `FP_API_KEY` — instead of +a session. The key is never written to disk, and the commands that need a *human* session +(`login`, `logout`, `orgs`, `agent`, `keys update`) exit `2` rather than half-run. Pass `--org +` if the key can act for more than one org; nothing is inherited from a saved login. + +**Multi-tenant:** if you belong to more than one org, pick the active tenant at login +(`fp login --org `) or per command with `--org ` (or `FP_ORG`). +`fp orgs` lists the orgs you can access; `fp orgs switch ` sets the default. + +**Exit codes** (stable, for scripting): `0` success · `1` unexpected error (e.g. an unhandled +server status) · `2` usage error · `3` cannot reach the dashboard · `4` not signed in or session +expired (run `fp login`) · `5` missing the required permission · `6` resource not found. + +Run `fp COMMAND -h` (or `--help`) for each command's filters, value formats, and JSON shape. +""" + +# Each example is its own paragraph (blank line between) on purpose: Typer collapses single +# newlines within an epilog paragraph into spaces, which would mash a normal Markdown list onto +# one line. Paragraph breaks survive as the single newlines Markdown needs for a clean list. +_EPILOG = """\ +**Examples** + +* `fp --json sessions --since 7d --status error` — runs that errored in the past week + +* `fp --json evals --score helpfulness:..0.5` — evaluations scoring ≤ 0.5 on helpfulness + +* `fp evals --aggregate --since 7d --env prod` — rolled-up eval health for the last week in prod + +* `fp --json errors --since 24h --env prod` — error summary for the last day in prod + +* `fp --json events --session-id run-001 --all | jq '.events[].payload'` — every event payload for one session, ready to pipe into a script +""" + +# -h is an alias for --help everywhere (Click reads this from the context). +_CTX = {"help_option_names": ["-h", "--help"]} + +app = typer.Typer( + no_args_is_help=True, + add_completion=False, + rich_markup_mode="markdown", + context_settings=_CTX, + help=_HELP, + epilog=_EPILOG, +) + + +# Render every ClickException as a clean one-line error (`✗ message`) instead of +# Typer's heavy red panel, so failures match the rest of the CLI's line aesthetic +# (`✓` / `○` / `›`). Usage errors keep a `try '… -h'` nudge. Patches the single +# function Typer calls for this; falls back silently if its internals change. +try: # pragma: no cover - exercised via real command output, not unit assertions + import typer.rich_utils as _rich_utils + + import re as _re + + from .errors import FpCliError as _FpCliError + + # Global options live BEFORE the command; putting one after it ("sessions --json") + # is the single most common mistake, so detect it and nudge toward the right form. + _GLOBAL_FLAGS = frozenset({ + "--json", "--base-url", "--org", "--token", "--api-key", "--timeout", + "--quiet", "--no-color", "--insecure", "--secure", + }) + + def _flag_placement_hint(message: str) -> "Optional[str]": + m = _re.search(r"No such option:? '?(--[a-z-]+)'?", message) + if m and m.group(1) in _GLOBAL_FLAGS: + return "global options go before the command, e.g. 'fp --json '" + return None + + def _wants_json() -> bool: + # output.is_json() is set by the group callback. Errors raised BEFORE it runs (an unknown + # command, a bad global option) never reached configure(), so fall back to the raw flag/env + # — otherwise `fp --json ` would print a human box instead of a JSON envelope. + if output.is_json(): + return True + import os + if os.environ.get("FP_JSON", "").strip().lower() in ("1", "true", "yes", "on"): + return True + return "--json" in sys.argv + + def _format_error_as_line(error: click.ClickException) -> None: + # Our typed errors carry a clean message; the HTTP status (for ApiError) rides in the JSON + # envelope's `status` field instead of being appended to the human text. Other Click + # exceptions (usage etc.) use their normal rendering. + if isinstance(error, _FpCliError): + message = error.message + else: + message = error.format_message() if hasattr(error, "format_message") else str(error) + # Click/Typer sometimes doubles the suggestion ("Did you mean 'x'? Did you + # mean 'x'?"); collapse the repeat so the one-line error stays clean. + message = _re.sub(r"(Did you mean [^?]+\?)(\s*\1)+", r"\1", message) + # A bare group invocation (`fp`, `fp keys`) already printed its help via + # no_args_is_help; Click then raises a message-less UsageError. Don't render an empty + # "✗" box (or an `{"error": ""}` envelope) on top of the help — just let Click exit. + if not message.strip(): + return + # Hint precedence: an explicit hint on the exception > the global-flag-placement + # nudge (more useful than the generic -h line) > the usage "try '… -h'" fallback. + hint = getattr(error, "hint", None) + if hint is None: + hint = _flag_placement_hint(message) + ctx = getattr(error, "ctx", None) + if hint is None and isinstance(error, click.UsageError) and ctx is not None: + hint = f"try '{ctx.command_path} -h' for help" + # Under --json, every failure is a machine-readable object on stdout (data channel), + # mirroring the success contract — never a Rich box. Click then exits with the same code. + if _wants_json(): + code = getattr(error, "exit_code", None) + if not isinstance(code, int): + code = 2 if isinstance(error, click.UsageError) else 1 + payload = {"error": message, "exit_code": code} + status = getattr(error, "status", None) + if isinstance(status, int): + payload["status"] = status + # Keep the server's request-id (set on ApiError) so an agent can correlate a failure + # with server logs — it's no longer appended to the human message. + request_id = getattr(error, "request_id", None) + if request_id: + payload["request_id"] = request_id + if hint: + payload["hint"] = hint + output.emit_json(payload) + else: + output.cli_error(message, hint=hint) + + _rich_utils.rich_format_error = _format_error_as_line + + # Replace the TOP-LEVEL help (bare `fp`, `fp --help`/`-h`) with the grouped + # commands screen; every subcommand's `--help` keeps Typer's default rendering. All three + # top-level paths flow through `rich_format_help`, so one override covers them. + _orig_rich_format_help = _rich_utils.rich_format_help + + def _format_help(*, obj, ctx, markup_mode): # noqa: ANN001 + if getattr(ctx, "parent", None) is None: # the root `fp` command + output.render_top_level_help() + else: + _orig_rich_format_help(obj=obj, ctx=ctx, markup_mode=markup_mode) + + _rich_utils.rich_format_help = _format_help +except Exception: + pass + + +def _version_callback(value: bool) -> None: + if value: + print(__version__) + raise typer.Exit() + + +def _from_command_line(ctx: typer.Context, param: str) -> bool: + """True when ``param`` was typed on the command line (not read from its env var). + + `--api-key` and `--token` have DIFFERENT precedence as flags than as env vars, and + by the time Typer hands us the value the two are indistinguishable. Click records + the source; ask it. Defaults to False if a Click without the API is ever in play, + which degrades to "treat it as env" — the conservative direction, since the only + thing it can cost is the both-flags usage error, never a wrong credential. + """ + try: + source = ctx.get_parameter_source(param) + except Exception: # pragma: no cover - only if Click drops the API + return False + return getattr(source, "name", None) == "COMMANDLINE" + + +@app.callback() +def main( + ctx: typer.Context, + json_output: bool = typer.Option( + False, "--json", envvar="FP_JSON", help="Emit JSON to stdout instead of a table." + ), + base_url: Optional[str] = typer.Option( + None, + "--base-url", + envvar="FP_DASHBOARD_URL", + metavar="URL", + help="Dashboard base URL. Defaults to https://app.befailproof.ai; " + "override for a self-hosted or dev instance (or set FP_DASHBOARD_URL).", + ), + org: Optional[str] = typer.Option( + None, + "--org", + envvar="FP_ORG", + metavar="SLUG", + help="Active org/tenant slug. Required if you belong to more than one org; " + "set it once at login (`login --org `) or here per command.", + ), + token: Optional[str] = typer.Option( + None, "--token", envvar="FP_TOKEN", help="Session token override (for CI/agents)." + ), + # `FP_API_KEY`, deliberately NOT the two names that already exist: + # * `AGENTEYE_KEY` is the collector's INGEST key, normally `events:add` only — + # picking it up here would make every read command 403 for no visible reason. + # * `AGENTEYE_API_KEY` is the dashboard service's own admin-grade key. Silently + # promoting an operator credential to "the CLI's identity" is a privilege + # surprise, and on a dashboard host both variables are typically already set. + # It also keeps the CLI's own `FP_TOKEN` / `FP_JSON` namespace. + api_key: Optional[str] = typer.Option( + None, + "--api-key", + envvar="FP_API_KEY", + metavar="KEY", + help="Authenticate as an API key against the versioned API instead of a user " + "session (for CI). Wins over --token's env var; passing both flags is an error. " + "Never saved to disk.", + ), + timeout: float = typer.Option(30.0, "--timeout", help="HTTP timeout in seconds."), + no_color: bool = typer.Option( + False, "--no-color", envvar="NO_COLOR", help="Disable coloured output." + ), + quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress status messages on stderr."), + insecure: Optional[bool] = typer.Option( + None, + "--insecure/--secure", + envvar="FP_INSECURE", + help="Skip TLS certificate verification (for self-signed/internal dashboards). " + "Saved at login; pass --secure to re-enable verification.", + ), + version: bool = typer.Option( + False, "--version", callback=_version_callback, is_eager=True, hidden=True, + help="Show version and exit.", + ), +) -> None: + """Resolve global options into the per-invocation AppState (flag > env > config).""" + if timeout <= 0: + raise click.BadParameter( + "must be a positive number of seconds.", param_hint="--timeout" + ) + cfg = cfgmod.load_config() + output.configure(no_color=no_color, quiet=quiet, json=json_output) + # Resolve the credential BEFORE telemetry starts: the distinct-id depends on it. + # `get_parameter_source` is what separates a flag from its env var — the values + # alone cannot, and the precedence rules differ between the two (see resolve_auth). + auth_mode, resolved_api_key, resolved_token = _context.resolve_auth( + api_key=api_key, + api_key_on_cli=_from_command_line(ctx, "api_key"), + token=token, + token_on_cli=_from_command_line(ctx, "token"), + saved_token=cfg.session_token, + ) + # In key mode force the ANONYMOUS distinct id. `_resolve_distinct_id` reads the + # SAVED config, so a CI box (or a laptop) where a human happens to be logged in + # would otherwise attribute every key-mode command to that person — an identity + # the key has nothing to do with. + analytics.init_analytics(cfg, force_anonymous=auth_mode is _context.AuthMode.API_KEY) + analytics.note_command(ctx.invoked_subcommand, json_output, auth_mode=auth_mode) + # Precedence: an explicit --insecure/--secure (or FP_INSECURE) wins; else the saved config. + resolved_insecure = cfg.insecure if insecure is None else insecure + if resolved_insecure and ctx.invoked_subcommand not in ("version", "help", None): + output.warn("⚠ TLS verification disabled (--insecure).") + # Active tenant: flag > FP_ORG env > saved config. Validate the shape early + # so a typo is a clean usage error rather than a confusing server rejection. + resolved_org = org or cfg.org + if resolved_org and not orgsmod.is_valid_org_slug(resolved_org): + raise click.BadParameter( + f"'{resolved_org}' is not a valid org slug " + "(lowercase letters, digits and single hyphens).", + param_hint="--org", + ) + # Base-URL resolution: explicit flag/env > saved config > the public default + # (`config.DEFAULT_BASE_URL`). An explicit empty string (`--base-url ""`, e.g. + # an unset CI var) is treated as "unset" and falls through, so a script never + # errors for lack of a URL — it lands on the hosted product. `or` (not + # `is not None`) is deliberate here: unlike `--token`, an empty base-url is a + # public endpoint choice, not an identity, so collapsing "" to the default is + # safe. + resolved_base = (base_url or None) or cfg.base_url or cfgmod.DEFAULT_BASE_URL + # A base-url without a scheme breaks httpx's cookie handling with a raw urllib + # ValueError (an uncaught traceback) — reject it up front as a clean usage error, + # mirroring the --org shape check above. (The default always has a scheme, so + # this only ever fires on a user-supplied value.) + if not resolved_base.lower().startswith(("http://", "https://")): + raise click.BadParameter( + f"'{resolved_base}' must start with http:// or https://.", + param_hint="--base-url", + ) + # `resolve_auth` above already applied the whole precedence ladder, including the + # rule that an EXPLICIT empty `--token ""` / `--api-key ""` (e.g. an unset CI var) + # means "no override" and must NOT silently fall back to the saved session — so a + # script can't unknowingly act as the stored identity. `base_url` intentionally + # differs (see `resolved_base` above): an empty URL is a public endpoint, not an + # identity, so it defaults. + # + # `api_key` is carried on the in-memory AppState ONLY. `CliConfig` has no field for + # it and must not grow one: a session token expires in ~24h, which bounds a leaked + # `cli.json`; an API key is valid until revoked, and nothing here could revoke it + # (`keys disable` needs `keys:disable`, which a scoped CI key will not hold). + ctx.obj = AppState( + json=json_output, + base_url=resolved_base, + token=resolved_token, + timeout=timeout, + config=cfg, + insecure=resolved_insecure, + org=resolved_org, + # `org` here is the global --org flag or FP_ORG env (None if neither) — + # the explicit choice, distinct from the saved-config fallback in `resolved_org`. + # Key mode sends ONLY this one (see `_context._org_header`). + org_explicit=org, + api_key=resolved_api_key, + auth_mode=auth_mode, + ) + + +@app.command() +def version(ctx: typer.Context) -> None: + """Show the CLI version in a small branded box. + + Follows the global option format — the **global** `--json` (before the command) makes it + emit `{"version": ""}` and nothing else: `fp --json version`. For a bare, + unboxed string use `fp --version`. + """ + if getattr(ctx.obj, "json", False): + output.emit_json({"version": __version__}) + else: + output.version_banner(__version__) + + +@app.command("help") +def help_cmd(ctx: typer.Context) -> None: + """Show this help and the available commands.""" + output.render_top_level_help() + + +auth_cmds.register(app) +orgs_cmds.register(app) +events_cmds.register(app) +sessions_cmds.register(app) +evals_cmds.register(app) +errors_cmds.register(app) +usage_cmds.register(app) +list_cmds.register(app) +keys_cmds.register(app) +queries_cmds.register(app) +users_cmds.register(app) +settings_cmds.register(app) +alerts_cmds.register(app) +audits_cmds.register(app) +incidents_cmds.register(app) +agent_cmds.register(app) +policies_cmds.register(app) +fleet_cmds.register(app) +guardrails_cmds.register(app) + + +def _elapsed_ms(start: float) -> int: + return int((time.monotonic() - start) * 1000) + + +def main_entry() -> None: + """Console-script entry point: run the CLI, then emit one telemetry event. + + Wraps ``app()`` so a single ``command_executed`` event (carrying the exit code and + duration) is captured however the command ends, then flushed. The original exit + code is preserved exactly — standalone Click has already printed any error and + raised ``SystemExit`` with the right status (see ``errors.py``). Tests invoke + ``app`` directly via ``CliRunner``, so they bypass this wrapper entirely. + """ + start = time.monotonic() + code = 0 + try: + app() + except SystemExit as exc: # normal path: Click exits with its status code + code = exc.code if isinstance(exc.code, int) else (0 if exc.code is None else 1) + except BaseException: # escaped Click (e.g. KeyboardInterrupt): record, then re-raise unchanged + analytics.capture_command(1, _elapsed_ms(start), sys.argv[1:]) + analytics.shutdown() + raise + analytics.capture_command(code, _elapsed_ms(start), sys.argv[1:]) + analytics.shutdown() + sys.exit(code) diff --git a/fp-cli/fp_cli/auth.py b/fp-cli/fp_cli/auth.py new file mode 100644 index 000000000..3b443c5aa --- /dev/null +++ b/fp-cli/fp_cli/auth.py @@ -0,0 +1,160 @@ +"""Email-OTP device login against the dashboard. + +Flow: ``/api/auth/otp/request`` (sends a code) then ``/api/auth/otp/verify``. +The verify response carries the session token in the ``ae_session`` **Set-Cookie** +header (not the JSON body), so we read it from ``response.cookies``. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional, Tuple + +import httpx + +from .config import CliConfig, save_config +from .errors import ApiError, AuthError, NetworkError + + +def _iso(dt: datetime) -> str: + return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _network_error(base_url: str, exc: Exception) -> NetworkError: + return NetworkError(f"Cannot reach FailproofAI Cloud at {base_url}: {exc}") + + +def request_otp( + base_url: str, + email: str, + *, + timeout: float = 30.0, + transport: Optional[httpx.BaseTransport] = None, + verify: bool = True, +) -> None: + """Ask the dashboard to email a login code. Always succeeds quietly for a + valid request (the server returns 200 even for unknown emails).""" + try: + with httpx.Client( + base_url=base_url.rstrip("/"), timeout=timeout, transport=transport, verify=verify + ) as client: + # Mark this as a CLI login so the server emails the paste-into-terminal + # OTP template (with no "open the dashboard" button) instead of the + # browser one. + response = client.post( + "/api/auth/otp/request", + json={"email": email}, + headers={"X-AgentEye-Client": "cli"}, + ) + except httpx.RequestError as exc: + raise _network_error(base_url, exc) + if response.status_code >= 400: + raise ApiError( + f"Failed to request a login code (HTTP {response.status_code}).", + status=response.status_code, + ) + + +def verify_otp( + base_url: str, + email: str, + code: str, + *, + timeout: float = 30.0, + transport: Optional[httpx.BaseTransport] = None, + verify: bool = True, +) -> Tuple[str, int, Dict[str, Any]]: + """Exchange the code for a session token. Returns ``(token, expires_in_secs, user)``.""" + try: + with httpx.Client( + base_url=base_url.rstrip("/"), timeout=timeout, transport=transport, verify=verify + ) as client: + response = client.post( + "/api/auth/otp/verify", json={"email": email, "code": code} + ) + except httpx.RequestError as exc: + raise _network_error(base_url, exc) + + if response.status_code == 401: + raise AuthError("That code didn't match or has expired. Sign in again with fp login.") + if response.status_code == 429: + raise ApiError( + "Too many attempts — wait a bit, then run fp login again.", + status=429, + ) + if response.status_code >= 400: + # The dashboard proxy collapses the server's wrong/expired-code 401 into a 500 (its + # `await res.json()` throws on the server's empty 401 body). So a non-401 4xx/5xx at the + # verify step is, in practice, a bad/expired code — surface it as a clean auth failure, + # not a raw "HTTP 500". (Real unreachability is a NetworkError, handled above.) + raise AuthError("That code didn't match or has expired. Sign in again with fp login.") + + token = response.cookies.get("ae_session") + if not token: + raise AuthError("The dashboard did not return a session token.") + + try: + body = response.json() + except Exception: + body = {} + if not isinstance(body, dict): + body = {} + + try: + expires_in = int(body.get("expires_in_secs")) + except (TypeError, ValueError): + expires_in = 86400 + + user = body.get("user") or {} + if not isinstance(user, dict): + user = {} + + return token, expires_in, user + + +def persist_session( + cfg: CliConfig, + base_url: str, + token: str, + expires_in_secs: int, + user: Dict[str, Any], + *, + insecure: bool = False, + org: Optional[str] = None, + now: Optional[datetime] = None, +) -> CliConfig: + now = now or datetime.now(timezone.utc) + cfg.base_url = base_url + cfg.session_token = token + cfg.expires_at = _iso(now + timedelta(seconds=expires_in_secs)) + cfg.email = (user or {}).get("email") or cfg.email + cfg.user_id = (user or {}).get("id") or cfg.user_id + cfg.insecure = insecure + if org is not None: + cfg.org = org # active tenant chosen at login + save_config(cfg) + return cfg + + +def logout( + base_url: str, + token: Optional[str], + *, + timeout: float = 30.0, + transport: Optional[httpx.BaseTransport] = None, + verify: bool = True, +) -> None: + """Best-effort server-side session revocation; never raises.""" + if not token: + return + try: + with httpx.Client( + base_url=base_url.rstrip("/"), + cookies={"ae_session": token}, + timeout=timeout, + transport=transport, + verify=verify, + ) as client: + client.post("/api/auth/logout") + except httpx.RequestError: + pass diff --git a/fp-cli/fp_cli/client.py b/fp-cli/fp_cli/client.py new file mode 100644 index 000000000..730dae211 --- /dev/null +++ b/fp-cli/fp_cli/client.py @@ -0,0 +1,1564 @@ +"""Pure query layer for the FailproofAI Cloud API. + +Every function takes a :class:`ClientContext` and returns plain dataclasses or +primitives. Nothing here prints or imports Typer/Rich — this is the surface a +future MCP server wraps directly. :class:`AuthMode` is defined here rather than in +``_context`` for the same reason the dependency runs this way round: ``_context`` +imports *this* module, and the transport below needs the enum at runtime to pick +bearer vs cookie. ``_context`` re-exports it. + +Two auth modes, and they never mix: + +* **session** — the ``ae_session`` cookie against the dashboard's ``/api/*`` + routes (its ``withAuth`` reads the cookie only; it does not accept a bearer). +* **api_key** — ``Authorization: Bearer `` against the server's curated + versioned API at ``/v1/*``. Every path is translated at the four request + chokepoints below (see :func:`_v1_path`), never at the ~70 call sites. +""" + +from __future__ import annotations + +import json as _json +import uuid +from dataclasses import dataclass +from enum import Enum +from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence, Union + +import httpx + +from .errors import ( + ApiError, + AuthError, + ForbiddenError, + KeyModeUnsupportedError, + NetworkError, + NotFoundError, +) +from .models import ( + AgentEvent, + Alert, + ApiKey, + Audit, + AuditFinding, + AuditRun, + DashboardUser, + Deployment, + Evaluation, + Incident, + IncidentComment, + IncidentSubscriber, + Machine, + Page, + PolicyRef, + PolicyVersion, + QueryResult, + SavedQuery, + Session, + SessionUser, + SettingRow, +) + +MAX_PAGE_SIZE = 200 + + +class AuthMode(str, Enum): + """Which credential this invocation carries — an explicit state, never inferred. + + Resolved once from the flags/env/saved config (see ``_context.resolve_auth``) and + then carried on both ``AppState`` and :class:`ClientContext`. It is an enum rather + than ``if state.api_key`` because the empty-string cases have to stay + distinguishable: ``--api-key ""`` is *key mode with no credential* (an error), NOT + "fall back to whatever session happens to be saved on this machine". + + ``str``-valued so the telemetry property is the enum itself — one closed set, no + second hand-written mapping to drift. + """ + + SESSION = "session" + API_KEY = "api_key" + NONE = "none" + + +@dataclass +class ClientContext: + base_url: str + token: Optional[str] = None + timeout: float = 30.0 + transport: Optional[httpx.BaseTransport] = None + verify: bool = True + org: Optional[str] = None # active tenant slug -> X-AgentEye-Org header + api_key: Optional[str] = None # bearer credential; only read in AuthMode.API_KEY + # Defaults to SESSION so every existing construction site (login, the org probe, + # tests) keeps its cookie behaviour unchanged. + auth_mode: AuthMode = AuthMode.SESSION + + +# --- /v1 translation (API-key mode only) ------------------------------------ +# +# The CLI's ~70 call sites all name the DASHBOARD's proxy path (`/api/...`). An +# API key cannot use those: `withAuth` reads the `ae_session` cookie and nothing +# else. Key mode therefore targets the server's curated versioned API directly, +# and the rewrite happens HERE — at the four request chokepoints — so no call +# site can forget it. +# +# It is deliberately NOT a blind `s|^/api|/v1|`. Two families would break +# silently under that: +# +# * `/api/evaluations/score-keys` is a RENAME invented by the proxy — the +# server route is `evaluations/score_keys` (see +# dashboard/app/api/evaluations/score-keys/route.ts). A blind swap 404s and +# the CLI reports a cheerful "Not found." +# * `/api/auth/*` and `/api/agent/*` have NO `/v1` equivalent at all. The auth +# and conversation routes are deliberately excluded from the version +# contract, and `agent/chat` + `agent/health` exist only in the dashboard — +# there is no server route to reach. +# +# Anything else is unclassified, and unclassified must be LOUD: a new call site +# that quietly passed through would produce a wrong URL, and the failure would +# arrive months later as "the CLI 404s in CI". `tests/test_v1_routing.py` +# AST-scans this file for every `/api/` literal and asserts each one lands in +# exactly one of these three buckets, then checks the resulting `/v1` paths +# against the server router's own `.route()` literals. + +_API_PREFIX = "/api/" + +# `/api//...` -> `/v1//...`, byte-identical below the prefix. +# Keyed on the FIRST path segment: a family is either mirrored wholesale or not +# at all, and listing families (not paths) keeps this honest without a 60-entry +# table that nobody would maintain. +_V1_MECHANICAL_FAMILIES = frozenset( + { + "access-granters", + "alerts", + "audits", + "evaluations", + "events", + "issues", + "keys", + "permission-sets", + "queries", + "sessions", + "settings", + # Organization usage / billing windows. Mechanical: the server registers + # /usage and /usage/windows inside `versioned_routes`, so both are on /v1. + "usage", + "users", + } +) + +# Exact paths the dashboard proxy renames on the way through. Checked BEFORE the +# family rule, which is why this must stay exact-match. +_V1_RENAMED = { + "/api/evaluations/score-keys": "/v1/evaluations/score_keys", +} + +# Families with no `/v1` route, and why — the message a user actually sees. +_V1_NO_EQUIVALENT = { + "auth": ( + "the sign-in endpoints are deliberately absent from /v1 — they take a browser " + "session, not an API key" + ), + "agent": ( + "the assistant is implemented by the dashboard, not the API — there is no /v1 " + "route behind it" + ), + # ROOT-ONLY on the server, and deliberately so: `/v1` is published on the + # dashboard host by the ingress, and publish/deploy/rollback are operator + # writes gated on `policies:write`. Exposing them there would put fleet + # mutation on the open internet. See the ROOT-ONLY block in + # `server/src/routes/mod.rs`. + "enforcement": ( + "cloud-managed policies are an operator surface — the fleet routes are " + "deliberately absent from /v1, which is internet-facing" + ), +} + + +def _v1_path(path: str) -> str: + """Translate a dashboard `/api/...` path to its `/v1/...` equivalent. + + Raises :class:`KeyModeUnsupportedError` (exit 2) for a family that has no `/v1` + route, and :class:`ApiError` for anything unclassified — never a silent + pass-through, which would send a request to a URL nobody chose. + """ + if path in _V1_RENAMED: + return _V1_RENAMED[path] + if not path.startswith(_API_PREFIX): + raise ApiError( + f"the CLI cannot address {path!r} with an API key: it is not a dashboard " + "/api/ path. This is a bug in the CLI, not in your command.", + hint="re-run without --api-key (session mode) and please report it", + ) + family = path[len(_API_PREFIX) :].split("/", 1)[0] + if family in _V1_NO_EQUIVALENT: + raise KeyModeUnsupportedError( + f"{path} has no API-key equivalent — {_V1_NO_EQUIVALENT[family]}", + hint="run this command with a signed-in session (fp login) instead", + ) + if family in _V1_MECHANICAL_FAMILIES: + return "/v1/" + path[len(_API_PREFIX) :] + raise ApiError( + f"the CLI does not know how to reach {path!r} on the versioned API — the " + "key-mode route table in client.py has no entry for it.", + hint="re-run without --api-key (session mode) and please report it", + ) + + +def _path(ctx: ClientContext, path: str) -> str: + """The path to actually request: `/v1/...` under an API key, `/api/...` otherwise.""" + if ctx.auth_mode is AuthMode.API_KEY: + return _v1_path(path) + return path + + +def _client(ctx: ClientContext, *, timeout: Any = None) -> httpx.Client: + headers = {"x-request-id": uuid.uuid4().hex} + cookies = None + # Bearer XOR cookie — an `else`, never two independent `if`s. Sending both + # would hand a human's `ae_session` to `/v1` alongside the key, and every + # positive assertion ("the bearer header is set") would still pass while the + # CLI leaked a session cookie into CI. tests/test_auth_mode.py asserts the + # NEGATIVE on both sides. + if ctx.auth_mode is AuthMode.API_KEY: + if ctx.api_key: + headers["Authorization"] = f"Bearer {ctx.api_key}" + else: + if ctx.token: + cookies = {"ae_session": ctx.token} + # The dashboard resolves the active org from this header (dashboard/lib/withAuth.ts); + # without it a multi-org user is rejected. Single-org users are fine either way. + # In key mode the caller only ever puts an EXPLICIT --org/FP_ORG in `org` + # (see `_context.build_context`), never the saved one. + if ctx.org: + headers["X-AgentEye-Org"] = ctx.org + return httpx.Client( + base_url=ctx.base_url.rstrip("/"), + cookies=cookies, + headers=headers, + timeout=ctx.timeout if timeout is None else timeout, + transport=ctx.transport, + verify=ctx.verify, + ) + + +def _csv(value: Optional[Union[str, Sequence[str]]]) -> Optional[str]: + if value is None: + return None + if isinstance(value, str): + return value or None + items = [str(v) for v in value if str(v)] + return ",".join(items) if items else None + + +def _bool(value: Optional[bool]) -> Optional[str]: + if value is None: + return None + return "true" if value else "false" + + +def _extract_error(response: httpx.Response) -> Optional[str]: + try: + data = response.json() + except Exception: + return None + if isinstance(data, dict): + msg = data.get("error") or data.get("message") + if not msg: + return None + # Fold in the server's raw `detail` (e.g. the underlying DB error for a failed + # `query run`) so an agent gets the actionable message, not just "query failed". + detail = data.get("detail") + if detail and str(detail) != str(msg): + return f"{msg}: {detail}" + return str(msg) + return None + + +def _required_permission(response: httpx.Response) -> Optional[str]: + """The ``required_permission`` slug the server names on a 403 (e.g. ``keys:create``), so the + CLI can tell the user exactly which grant they're missing instead of a bare ``forbidden``.""" + try: + data = response.json() + except Exception: + return None + if isinstance(data, dict) and data.get("required_permission"): + return str(data["required_permission"]) + return None + + +def _raise_for_status(response: httpx.Response, ctx: ClientContext) -> None: + key_mode = ctx.auth_mode is AuthMode.API_KEY + if response.status_code < 400: + # A 3xx to /login means the request never reached the API: some front door + # (Next.js middleware) answered it. httpx does not follow redirects, so + # without this the body is empty/HTML and the caller reports "the dashboard + # returned a malformed response" — which sends people hunting for a server + # bug. In key mode it has exactly one cause worth naming. + if 300 <= response.status_code < 400 and key_mode: + location = response.headers.get("location", "") + if "/login" in location: + raise ApiError( + "/v1 is not routed at this base URL — the request was redirected to " + "the dashboard's login page, so it landed on the web app instead of " + "the API.", + status=response.status_code, + request_id=response.headers.get("x-request-id"), + hint="point --base-url at the server itself, e.g. http://localhost:8080", + ) + # Session mode is deliberately left alone here: its 3xx-to-/login is the + # ordinary "your cookie is gone" case and changing its exit code is a + # separate contract change. + return + request_id = response.headers.get("x-request-id") + message = _extract_error(response) + if response.status_code == 401: + if key_mode: + raise AuthError( + "The API key was rejected. It may be revoked, mistyped, or issued by a " + "different deployment than --base-url points at." + ) + raise AuthError("Session expired or not logged in. Run fp login.") + if response.status_code == 403: + needed = _required_permission(response) + if key_mode: + # Genuinely ambiguous, and the server cannot disambiguate it for us: a key + # acting for an org it was not issued for gets the SAME 403 as a key missing + # a permission, on purpose — telling the two apart would let a key holder + # enumerate which orgs exist. So name both causes rather than guess one. + what = ( + f"the API key is missing the {needed} permission" + if needed + else "the API key is not allowed to do this" + ) + raise ForbiddenError( + f"{what}, or it cannot act for this org — the server answers 403 for both.", + hint="check the key's grants, and the org you targeted with --org / FP_ORG", + ) + if needed: + raise ForbiddenError(f"you don't have the {needed} permission") + raise ForbiddenError(message or "you don't have permission for this action") + if response.status_code == 404: + # In key mode a 404 has TWO very different causes, and the wrong reading + # sends people hunting for a server bug that isn't there: + # 1. the endpoint genuinely has no such record — the API answered, in JSON; + # 2. `/v1` is not routed at this origin at all, so something else answered. + # + # (2) is the likeliest first-run mistake: pointing --base-url at a + # dashboard whose front door does not forward /v1. It used to surface as a + # 3xx to /login, which the branch above names — but a dashboard that + # correctly declines to auth-gate /v1 returns its own 404 instead, and that + # is indistinguishable from (1) on the status code alone. + # + # The tell is the content type: our API always answers JSON, so an HTML + # body means a web app answered a request meant for the API. + if key_mode: + content_type = response.headers.get("content-type", "").lower() + if "html" in content_type: + raise ApiError( + "/v1 is not routed at this base URL — an HTML page answered, so the " + "request reached a web app rather than the API.", + status=404, + request_id=request_id, + hint="point --base-url at the server itself, e.g. http://localhost:8080", + ) + raise NotFoundError(message or "Not found.") + if response.status_code == 429: + retry_after = response.headers.get("retry-after") + wait = f" Retry after {retry_after}s." if retry_after else " Please wait a moment and try again." + raise ApiError( + (message or "Rate limited — too many requests.") + wait, + status=429, + request_id=request_id, + ) + raise ApiError( + message or f"Request failed with status {response.status_code}.", + status=response.status_code, + request_id=request_id, + ) + + +def _get_json(ctx: ClientContext, path: str, params: Optional[Dict[str, Any]] = None) -> Any: + clean = {k: v for k, v in (params or {}).items() if v is not None} + url = _path(ctx, path) # chokepoint 1 of 4 for the /api -> /v1 rewrite + try: + with _client(ctx) as client: + response = client.get(url, params=clean) + except httpx.RequestError as exc: + raise NetworkError( + f"Cannot reach FailproofAI Cloud at {ctx.base_url}: {exc}" + ) + _raise_for_status(response, ctx) + # A 2xx with an empty or non-JSON body is anomalous for a read (e.g. a proxy or + # captive portal returning an HTML 200). Surface it as a clean error instead of + # letting `response.json()` raise a raw JSONDecodeError traceback. + try: + return response.json() + except ValueError: + raise ApiError( + "The dashboard returned a malformed (non-JSON) response.", + status=response.status_code, + request_id=response.headers.get("x-request-id"), + ) + + +def _request_json( + ctx: ClientContext, + method: str, + path: str, + *, + json_body: Any = None, + params: Optional[Dict[str, Any]] = None, +) -> Any: + """Issue a write request and return the parsed JSON body (or ``{}`` if empty). + + Mirrors :func:`_get_json`: maps transport failures to :class:`NetworkError` and + applies the shared 401/403/404/4xx/5xx mapping via :func:`_raise_for_status`, so + every write inherits the same exit-code contract. Tolerates an empty/204 body. + """ + clean = {k: v for k, v in (params or {}).items() if v is not None} or None + url = _path(ctx, path) # chokepoint 2 of 4 for the /api -> /v1 rewrite + try: + with _client(ctx) as client: + response = client.request(method, url, json=json_body, params=clean) + except httpx.RequestError as exc: + raise NetworkError( + f"Cannot reach FailproofAI Cloud at {ctx.base_url}: {exc}" + ) + _raise_for_status(response, ctx) + if not response.content: + return {} + try: + return response.json() + except ValueError: + return {} + + +def _post_json(ctx: ClientContext, path: str, json_body: Any = None, *, params: Optional[Dict[str, Any]] = None) -> Any: + return _request_json(ctx, "POST", path, json_body=json_body, params=params) + + +def _put_json(ctx: ClientContext, path: str, json_body: Any = None) -> Any: + return _request_json(ctx, "PUT", path, json_body=json_body) + + +def _patch_json(ctx: ClientContext, path: str, json_body: Any = None) -> Any: + return _request_json(ctx, "PATCH", path, json_body=json_body) + + +def _delete(ctx: ClientContext, path: str) -> Any: + return _request_json(ctx, "DELETE", path) + + +# --- Auth / identity -------------------------------------------------------- + + +def get_session_user(ctx: ClientContext) -> SessionUser: + """GET /api/auth/session — the currently authenticated user.""" + return SessionUser.from_dict(_get_json(ctx, "/api/auth/session")) + + +def org_is_accessible(ctx: ClientContext, slug: str) -> bool: + """Return True iff the authenticated user can act in org ``slug`` — i.e. the + org **exists** AND is granted to them (a membership, or an instance admin with + access). Probes a cheap org-scoped endpoint with ``X-AgentEye-Org: slug``; + HTTP 200 → accessible, 403/404 → the org does not exist or is not theirs. + + Used to validate an explicitly-requested tenant (``--org`` / ``FP_ORG``) + before it is saved, so a non-existent or unauthorised slug is rejected up front + instead of being persisted and breaking every later command. + + Raises :class:`AuthError` on 401 (dead session) and :class:`NetworkError` on a + transport failure, so a transient outage is never misreported as a bad org. + """ + probe = ClientContext( + base_url=ctx.base_url, + token=ctx.token, + timeout=ctx.timeout, + verify=ctx.verify, + org=slug, + api_key=ctx.api_key, + auth_mode=ctx.auth_mode, + ) + try: + with _client(probe) as client: + # Probe an auth-only endpoint (no specific data permission): it returns 200 for a + # member OR an instance admin granted the org, and 403/404 otherwise. Using a + # permission-gated route (e.g. /api/evaluations/environments) wrongly rejected an + # instance admin who has org access but no data perms. + # chokepoint 3 of 4 — this one builds its own client rather than going + # through _get_json, so it needs the rewrite applied by hand. + response = client.get(_path(probe, "/api/access-granters")) + except httpx.RequestError as exc: + raise NetworkError( + f"Cannot reach FailproofAI Cloud at {ctx.base_url}: {exc}" + ) + if response.status_code == 200: + return True + if response.status_code == 401: + raise AuthError("Session expired or not logged in. Run fp login.") + # 403 / 404 (and anything else non-2xx) → the org is not accessible to this user. + return False + + +# --- Events ----------------------------------------------------------------- + + +def _event_query_params( + *, + session_id: Optional[Union[str, Sequence[str]]], + agent_id: Optional[Union[str, Sequence[str]]], + event_type: Optional[Union[str, Sequence[str]]], + environment: Optional[Union[str, Sequence[str]]], + error_type: Optional[Union[str, Sequence[str]]], + errored: Optional[bool], + order: Optional[str], + search: Optional[Sequence[str]], + search_exclude: Optional[Union[str, Sequence[str]]], + ts_from: Optional[str], + ts_to: Optional[str], + cursor: Optional[Union[int, str]], + limit: Optional[int], +) -> Dict[str, Any]: + """The shared filter/cursor/order query params for the events feeds. + + ``/api/events`` (full) and ``/api/events/summary`` (light) accept an IDENTICAL query + surface and emit an interchangeable ``"|"`` cursor, so both feeds build their + params here — they can never drift. + """ + # session_id / agent_id are CSV multi-value on the wire (server `IN (...)`); `_csv` + # serializes a list to `a,b` and passes a bare string through unchanged (back-compat). + params: Dict[str, Any] = { + "session_id": _csv(session_id), + "agent_id": _csv(agent_id), + "event_type": _csv(event_type), + "environment": _csv(environment), + "error_type": _csv(error_type), + # the server reads `errored` only when truthy (matches the dashboard `/errors` view). + "errored": "true" if errored else None, + "order": order, + "search_exclude": _csv(search_exclude), + "ts_from": ts_from, + "ts_to": ts_to, + "cursor": cursor, + "limit": limit, + } + # `search` is free text — sent as REPEATED params (not CSV), so httpx needs a list. + terms = [s for s in (search or []) if s and s.strip()] + if terms: + params["search"] = terms + return params + + +def list_events( + ctx: ClientContext, + *, + session_id: Optional[Union[str, Sequence[str]]] = None, + agent_id: Optional[Union[str, Sequence[str]]] = None, + event_type: Optional[Union[str, Sequence[str]]] = None, + environment: Optional[Union[str, Sequence[str]]] = None, + error_type: Optional[Union[str, Sequence[str]]] = None, + errored: Optional[bool] = None, + order: Optional[str] = None, + search: Optional[Sequence[str]] = None, + search_exclude: Optional[Union[str, Sequence[str]]] = None, + ts_from: Optional[str] = None, + ts_to: Optional[str] = None, + cursor: Optional[Union[int, str]] = None, + limit: Optional[int] = None, +) -> Page[AgentEvent]: + """GET /api/events — the FULL feed (includes the fat ``payload`` column). + + Heavy at scale (payload is ~99.9% of the events table, read under ``FINAL``). Use only + for the bounded, payload-requesting paths (``events --full`` / + ``--fields payload``). The default list + all of ``errors`` use + :func:`list_event_summaries` instead. + """ + params = _event_query_params( + session_id=session_id, agent_id=agent_id, event_type=event_type, + environment=environment, error_type=error_type, errored=errored, order=order, + search=search, search_exclude=search_exclude, ts_from=ts_from, ts_to=ts_to, + cursor=cursor, limit=limit, + ) + data = _get_json(ctx, "/api/events", params) + items = [AgentEvent.from_dict(e) for e in data.get("events", [])] + return Page(items=items, next_cursor=data.get("next_cursor")) + + +def list_event_summaries( + ctx: ClientContext, + *, + session_id: Optional[Union[str, Sequence[str]]] = None, + agent_id: Optional[Union[str, Sequence[str]]] = None, + event_type: Optional[Union[str, Sequence[str]]] = None, + environment: Optional[Union[str, Sequence[str]]] = None, + error_type: Optional[Union[str, Sequence[str]]] = None, + errored: Optional[bool] = None, + order: Optional[str] = None, + search: Optional[Sequence[str]] = None, + search_exclude: Optional[Union[str, Sequence[str]]] = None, + ts_from: Optional[str] = None, + ts_to: Optional[str] = None, + cursor: Optional[Union[int, str]] = None, + limit: Optional[int] = None, +) -> Page[AgentEvent]: + """GET /api/events/summary — the LIGHT, payload-free feed (PR #338). + + Same filters/order and an interchangeable cursor as :func:`list_events`, but the server + projects only the display columns (no ``payload``): it returns the precomputed + ``summary`` / ``is_error`` plus ``error_type`` / ``output_tokens`` / context-window + fields. This is the CLI's default read path: ordinary list/errors reads do not touch the + fat payload column. A free-text ``search`` is the deliberate exception: the response is + still payload-free, but the server scans payload in the WHERE clause to find matches. + """ + params = _event_query_params( + session_id=session_id, agent_id=agent_id, event_type=event_type, + environment=environment, error_type=error_type, errored=errored, order=order, + search=search, search_exclude=search_exclude, ts_from=ts_from, ts_to=ts_to, + cursor=cursor, limit=limit, + ) + data = _get_json(ctx, "/api/events/summary", params) + items = [AgentEvent.from_dict(e) for e in data.get("events", [])] + return Page(items=items, next_cursor=data.get("next_cursor")) + + +# --- Event facets & analytics ---------------------------------------------- + +_FACET_PATHS = { + "agent_ids": "/api/events/agent_ids", + "event_types": "/api/events/event_types", + "models": "/api/events/models", + "tool_names": "/api/events/tool_names", + "hook_names": "/api/events/hook_names", + "error_types": "/api/events/error_types", + "trigger_events": "/api/events/trigger_events", + "environments": "/api/events/environments", + # Evaluation score keys (a distinct endpoint, not /api/events) — the source for + # the sessions-page score-filter dropdown; needs `evaluations:read`. + "score_filters": "/api/evaluations/score-keys", +} +FACET_KINDS = tuple(_FACET_PATHS.keys()) + + +def list_facet(ctx: ClientContext, kind: str) -> List[str]: + """GET /api/events/ — distinct facet values (a bare JSON array).""" + data = _get_json(ctx, _FACET_PATHS[kind]) + return [str(x) for x in data] if isinstance(data, list) else [] + + +def get_usage(ctx: ClientContext) -> Dict[str, Any]: + """GET /api/usage — the active org's current 30-day metering window.""" + data = _get_json(ctx, "/api/usage") + if not isinstance(data, dict): + raise ApiError("The dashboard returned an invalid usage response.") + return data + + +def event_error_summary( + ctx: ClientContext, + *, + session_id: Optional[str] = None, + agent_id: Optional[str] = None, + event_type: Optional[Union[str, Sequence[str]]] = None, + error_type: Optional[Union[str, Sequence[str]]] = None, + environment: Optional[Union[str, Sequence[str]]] = None, + ts_from: Optional[str] = None, + ts_to: Optional[str] = None, + search: Optional[Sequence[str]] = None, + search_exclude: Optional[Union[str, Sequence[str]]] = None, +) -> Dict[str, Any]: + """GET /api/events/error_summary — {total, sessions, agents, last_ts, bins}.""" + params: Dict[str, Any] = { + "session_id": session_id, + "agent_id": agent_id, + "event_type": _csv(event_type), + "error_type": _csv(error_type), + "environment": _csv(environment), + "ts_from": ts_from, + "ts_to": ts_to, + "search_exclude": _csv(search_exclude), + } + terms = [s for s in (search or []) if s and s.strip()] + if terms: + params["search"] = terms + data = _get_json(ctx, "/api/events/error_summary", params) + return data if isinstance(data, dict) else {} + + +# --- Evaluations / sessions ------------------------------------------------- + + +def list_evaluations( + ctx: ClientContext, + *, + session_id: Optional[str] = None, + agent_id: Optional[str] = None, + environment: Optional[Union[str, Sequence[str]]] = None, + status: Optional[str] = None, + score_filters: Optional[str] = None, + latest_per_session: Optional[bool] = None, + ts_from: Optional[str] = None, + ts_to: Optional[str] = None, + cursor: Optional[int] = None, + limit: Optional[int] = None, +) -> Page[Evaluation]: + data = _get_json( + ctx, + "/api/evaluations", + { + "session_id": session_id, + "agent_id": agent_id, + "environment": _csv(environment), + "status": status, + "score_filters": score_filters, + "latest_per_session": _bool(latest_per_session), + "ts_from": ts_from, + "ts_to": ts_to, + "cursor": cursor, + "limit": limit, + }, + ) + items = [Evaluation.from_dict(e) for e in data.get("evaluations", [])] + return Page(items=items, next_cursor=data.get("next_cursor")) + + +def list_sessions( + ctx: ClientContext, + *, + session_id: Optional[Union[str, Sequence[str]]] = None, + agent_id: Optional[Union[str, Sequence[str]]] = None, + environment: Optional[Union[str, Sequence[str]]] = None, + status: Optional[Union[str, Sequence[str]]] = None, + score_filters: Optional[str] = None, + ts_from: Optional[str] = None, + ts_to: Optional[str] = None, + cursor: Optional[str] = None, + limit: Optional[int] = None, +) -> Page[Session]: + """GET /api/sessions — one row per agent run (the endpoint the dashboard's sessions page + uses). Every filter is CSV multi-value on the wire → server ``IN(...)`` (UNION within a + filter, AND across filters); ``status`` matches each session's LATEST evaluation status. + ``cursor`` is the opaque string keyset cursor (``"|"``).""" + data = _get_json( + ctx, + "/api/sessions", + { + "session_id": _csv(session_id), + "agent_id": _csv(agent_id), + "environment": _csv(environment), + "status": _csv(status), + "score_filters": score_filters, + "ts_from": ts_from, + "ts_to": ts_to, + "cursor": cursor, + "limit": limit, + }, + ) + items = [Session.from_dict(s) for s in data.get("sessions", [])] + return Page(items=items, next_cursor=data.get("next_cursor")) + + +def evaluation_aggregate( + ctx: ClientContext, + *, + session_id: Optional[str] = None, + agent_id: Optional[str] = None, + environment: Optional[Union[str, Sequence[str]]] = None, + status: Optional[str] = None, + score_filters: Optional[str] = None, + latest_per_session: Optional[bool] = None, + featured_keys: Optional[Union[str, Sequence[str]]] = None, + ts_from: Optional[str] = None, + ts_to: Optional[str] = None, +) -> Dict[str, Any]: + """GET /api/evaluations/aggregate — rolled-up status/score stats + timeline.""" + data = _get_json( + ctx, + "/api/evaluations/aggregate", + { + "session_id": session_id, + "agent_id": agent_id, + "environment": _csv(environment), + "status": status, + "score_filters": score_filters, + "latest_per_session": _bool(latest_per_session), + "featured_keys": _csv(featured_keys), + "ts_from": ts_from, + "ts_to": ts_to, + }, + ) + return data if isinstance(data, dict) else {} + + +# --- API keys --------------------------------------------------------------- + + +def list_keys(ctx: ClientContext) -> List[ApiKey]: + """GET /api/keys — all keys for the org (metadata only; a bare JSON array).""" + data = _get_json(ctx, "/api/keys") + return [ApiKey.from_dict(k) for k in (data if isinstance(data, list) else [])] + + +def list_permission_sets(ctx: ClientContext) -> Dict[str, List[str]]: + """GET /api/permission-sets — the org's permission sets (built-in + custom) as a + ``{name: [permissions]}`` map. Used to expand a ``--permission-set`` for a KEY client-side + (keys store a flat permission list, so the CLI seeds from the set like the dashboard's + SetPicker). Returns ``{}`` on any non-list/odd shape.""" + data = _get_json(ctx, "/api/permission-sets") + if not isinstance(data, list): + return {} + out: Dict[str, List[str]] = {} + for s in data: + if isinstance(s, dict) and s.get("name"): + out[str(s["name"])] = [str(p) for p in (s.get("permissions") or [])] + return out + + +def create_key(ctx: ClientContext, *, name: str, key: str, permissions: Sequence[str]) -> ApiKey: + """POST /api/keys — create a key. The caller supplies the secret (``key``); + the response carries no secret (it must be shown to the user once, by the caller).""" + data = _post_json(ctx, "/api/keys", {"name": name, "key": key, "permissions": list(permissions)}) + return ApiKey.from_dict(data) + + +def update_key(ctx: ClientContext, key_id: str, *, permissions: Sequence[str]) -> ApiKey: + """PATCH /api/keys/{id} — replace the key's permission grants.""" + data = _patch_json(ctx, f"/api/keys/{key_id}", {"permissions": list(permissions)}) + return ApiKey.from_dict(data) + + +def disable_key(ctx: ClientContext, key_id: str) -> None: + """POST /api/keys/{id}/disable — revoke a key (irreversible).""" + _post_json(ctx, f"/api/keys/{key_id}/disable") + + +def regenerate_key(ctx: ClientContext, key_id: str) -> str: + """POST /api/keys/{id}/regenerate — rotate the secret; returns the NEW secret once.""" + data = _post_json(ctx, f"/api/keys/{key_id}/regenerate") + return str(data.get("key", "")) if isinstance(data, dict) else "" + + +# --- Saved queries / SQL runner --------------------------------------------- + + +def list_saved_queries(ctx: ClientContext) -> List[SavedQuery]: + """GET /api/queries — saved queries for the org (response is {"queries": [...]}).""" + data = _get_json(ctx, "/api/queries") + items = data.get("queries", []) if isinstance(data, dict) else (data if isinstance(data, list) else []) + return [SavedQuery.from_dict(q) for q in items] + + +def create_saved_query( + ctx: ClientContext, + *, + name: str, + sql_text: str, + description: str = "", + params: Optional[List[Dict[str, Any]]] = None, +) -> SavedQuery: + """POST /api/queries — create a saved query.""" + body = {"name": name, "description": description, "sql_text": sql_text, "params": params or []} + return SavedQuery.from_dict(_post_json(ctx, "/api/queries", body)) + + +def update_saved_query( + ctx: ClientContext, + query_id: str, + *, + name: str, + sql_text: str, + description: str = "", + params: Optional[List[Dict[str, Any]]] = None, +) -> SavedQuery: + """PUT /api/queries/{id} — full replace of a saved query.""" + body = {"name": name, "description": description, "sql_text": sql_text, "params": params or []} + return SavedQuery.from_dict(_put_json(ctx, f"/api/queries/{query_id}", body)) + + +def delete_saved_query(ctx: ClientContext, query_id: str) -> None: + """DELETE /api/queries/{id}.""" + _delete(ctx, f"/api/queries/{query_id}") + + +def run_query( + ctx: ClientContext, + *, + sql: Optional[str] = None, + query_id: Optional[str] = None, + params: Optional[List[Any]] = None, +) -> QueryResult: + """POST /api/queries/run — execute inline SQL or a saved query (read-only pool).""" + body: Dict[str, Any] = {"params": params or []} + if sql is not None: + body["sql"] = sql + if query_id is not None: + body["query_id"] = query_id + return QueryResult.from_dict(_post_json(ctx, "/api/queries/run", body)) + + +def query_schema(ctx: ClientContext) -> Dict[str, Any]: + """GET /api/queries/schema — {schema, tables:[{name, columns:[{name,type}]}]}.""" + data = _get_json(ctx, "/api/queries/schema") + return data if isinstance(data, dict) else {} + + +# --- Users ------------------------------------------------------------------ + + +def list_users(ctx: ClientContext) -> List[DashboardUser]: + """GET /api/users — all org members (a bare JSON array).""" + data = _get_json(ctx, "/api/users") + return [DashboardUser.from_dict(u) for u in (data if isinstance(data, list) else [])] + + +def get_user(ctx: ClientContext, user_id: str) -> DashboardUser: + """GET /api/users/{id}.""" + return DashboardUser.from_dict(_get_json(ctx, f"/api/users/{user_id}")) + + +def _user_perm_body(permission_set, permission_added, permission_removed) -> Dict[str, Any]: + body: Dict[str, Any] = {} + if permission_set is not None: + body["permission_set"] = permission_set + if permission_added is not None: + body["permission_added"] = list(permission_added) + if permission_removed is not None: + body["permission_removed"] = list(permission_removed) + return body + + +def create_user( + ctx: ClientContext, + *, + email: str, + permission_set: Optional[str] = None, + permission_added: Optional[Sequence[str]] = None, + permission_removed: Optional[Sequence[str]] = None, +) -> DashboardUser: + """POST /api/users — invite/create a member.""" + body = {"email": email, **_user_perm_body(permission_set, permission_added, permission_removed)} + return DashboardUser.from_dict(_post_json(ctx, "/api/users", body)) + + +def update_user( + ctx: ClientContext, + user_id: str, + *, + permission_set: Optional[str] = None, + permission_added: Optional[Sequence[str]] = None, + permission_removed: Optional[Sequence[str]] = None, +) -> DashboardUser: + """PUT /api/users/{id} — change a member's grants.""" + body = _user_perm_body(permission_set, permission_added, permission_removed) + return DashboardUser.from_dict(_put_json(ctx, f"/api/users/{user_id}", body)) + + +def disable_user(ctx: ClientContext, user_id: str) -> None: + """DELETE /api/users/{id} — disable a member (reversible via enable).""" + _delete(ctx, f"/api/users/{user_id}") + + +def enable_user(ctx: ClientContext, user_id: str) -> DashboardUser: + """POST /api/users/{id}/enable — re-enable a disabled member.""" + return DashboardUser.from_dict(_post_json(ctx, f"/api/users/{user_id}/enable")) + + +# --- Settings --------------------------------------------------------------- + + +def list_settings(ctx: ClientContext) -> List[SettingRow]: + """GET /api/settings — {settings:[...]}.""" + data = _get_json(ctx, "/api/settings") + items = data.get("settings", []) if isinstance(data, dict) else [] + return [SettingRow.from_dict(s) for s in items] + + +def get_settings_schema(ctx: ClientContext) -> List[Dict[str, Any]]: + """Registry metadata per setting. + + There is no dedicated schema endpoint on the dashboard — each ``GET /api/settings`` + row carries its own ``schema`` blob, so derive the metadata from the settings list. + """ + rows = list_settings(ctx) + return [{"key": r.key, **(r.schema or {})} for r in rows] + + +def put_setting(ctx: ClientContext, key: str, value: Any) -> SettingRow: + """PUT /api/settings/{key} — body is always wrapped as {"value": ...}.""" + return SettingRow.from_dict(_put_json(ctx, f"/api/settings/{key}", {"value": value})) + + +# --- Alerts ----------------------------------------------------------------- + + +def list_alerts(ctx: ClientContext) -> List[Alert]: + """GET /api/alerts — alert definitions for the org (bare array).""" + data = _get_json(ctx, "/api/alerts") + return [Alert.from_dict(a) for a in (data if isinstance(data, list) else [])] + + +def create_alert(ctx: ClientContext, body: Dict[str, Any]) -> Dict[str, Any]: + """POST /api/alerts — returns {id, created_at}.""" + return _post_json(ctx, "/api/alerts", body) + + +def update_alert(ctx: ClientContext, alert_id: str, body: Dict[str, Any]) -> Dict[str, Any]: + """PUT /api/alerts/{id} — returns {id, updated_at}.""" + return _put_json(ctx, f"/api/alerts/{alert_id}", body) + + +def delete_alert(ctx: ClientContext, alert_id: str) -> None: + """DELETE /api/alerts/{id}.""" + _delete(ctx, f"/api/alerts/{alert_id}") + + +def test_alert(ctx: ClientContext, alert_id: str, channels: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]: + """POST /api/alerts/{id}/test — fire a test notification; {ok, synthetic_incident_id}.""" + return _post_json(ctx, f"/api/alerts/{alert_id}/test", {"channels": channels} if channels else {}) + + +# --- Incidents -------------------------------------------------------------- + + +def list_incidents( + ctx: ClientContext, + *, + state: Optional[str] = None, + alert_id: Optional[str] = None, + limit: Optional[int] = None, +) -> List[Incident]: + """GET /api/issues (or /api/alerts/{id}/issues when alert_id given). The + alert-scoped path honours ``state``/``limit`` too — pass them so the filters aren't silently + dropped on that path (``_get_json`` omits None params).""" + if alert_id: + data = _get_json(ctx, f"/api/alerts/{alert_id}/issues", {"state": state, "limit": limit}) + else: + data = _get_json(ctx, "/api/issues", {"state": state, "limit": limit}) + return [Incident.from_dict(i) for i in (data if isinstance(data, list) else [])] + + +def count_incidents(ctx: ClientContext, *, state: Optional[str] = None) -> int: + """GET /api/issues/count — {count}.""" + data = _get_json(ctx, "/api/issues/count", {"state": state}) + if not isinstance(data, dict): + return 0 + try: + return int(data.get("count", 0)) + except (TypeError, ValueError): + return 0 + + +def get_incident(ctx: ClientContext, incident_id: str) -> Incident: + """GET /api/issues/{id} — full detail (comments, subscribers, activity).""" + return Incident.from_dict(_get_json(ctx, f"/api/issues/{incident_id}")) + + +def ack_incident(ctx: ClientContext, incident_id: str) -> None: + _post_json(ctx, f"/api/issues/{incident_id}/ack") + + +def assign_incident(ctx: ClientContext, incident_id: str, assignees: Sequence[str]) -> None: + """POST /api/issues/{id}/assign — replace the assignee list (server validates each email).""" + _post_json(ctx, f"/api/issues/{incident_id}/assign", {"assignees": list(assignees)}) + + +def resolve_incident(ctx: ClientContext, incident_id: str) -> None: + _post_json(ctx, f"/api/issues/{incident_id}/resolve") + + +def list_incident_comments(ctx: ClientContext, incident_id: str) -> List[IncidentComment]: + data = _get_json(ctx, f"/api/issues/{incident_id}/comments") + return [IncidentComment.from_dict(c) for c in (data if isinstance(data, list) else [])] + + +def create_incident_comment(ctx: ClientContext, incident_id: str, body: str) -> IncidentComment: + data = _post_json(ctx, f"/api/issues/{incident_id}/comments", {"body": body}) + return IncidentComment.from_dict(data) + + +def delete_incident_comment(ctx: ClientContext, incident_id: str, comment_id: str) -> None: + _delete(ctx, f"/api/issues/{incident_id}/comments/{comment_id}") + + +def list_incident_subscribers(ctx: ClientContext, incident_id: str) -> List[IncidentSubscriber]: + data = _get_json(ctx, f"/api/issues/{incident_id}/subscribers") + return [IncidentSubscriber.from_dict(s) for s in (data if isinstance(data, list) else [])] + + +def subscribe_incident(ctx: ClientContext, incident_id: str, email: Optional[str] = None) -> None: + _post_json(ctx, f"/api/issues/{incident_id}/subscribe", {"email": email} if email else {}) + + +def unsubscribe_incident(ctx: ClientContext, incident_id: str, email: Optional[str] = None) -> None: + _post_json(ctx, f"/api/issues/{incident_id}/unsubscribe", {"email": email} if email else {}) + + +def open_incident( + ctx: ClientContext, + *, + summary: str, + alert_id: Optional[str] = None, + severity: Optional[str] = None, + title: Optional[str] = None, +) -> Dict[str, Any]: + """POST /api/alerts/{id}/issues (linked) or /api/issues (standalone). + + ``title`` is required by the server on the standalone path (an orphan has no + parent alert whose name it could borrow) and optional on the linked path, + where the server falls back to the alert's own name. + """ + if alert_id: + linked: Dict[str, Any] = {"summary": summary} + if title: + linked["title"] = title + return _post_json(ctx, f"/api/alerts/{alert_id}/issues", linked) + body: Dict[str, Any] = {"summary": summary} + if title: + body["title"] = title + if severity: + body["severity"] = severity + return _post_json(ctx, "/api/issues", body) + + +# --- Audits ----------------------------------------------------------------- + + +def list_audits(ctx: ClientContext) -> List[Audit]: + """GET /api/audits — audit definitions for the org (bare array).""" + data = _get_json(ctx, "/api/audits") + return [Audit.from_dict(a) for a in (data if isinstance(data, list) else [])] + + +def get_audit(ctx: ClientContext, audit_id: str) -> Audit: + """GET /api/audits/{id} — one audit definition.""" + return Audit.from_dict(_get_json(ctx, f"/api/audits/{audit_id}")) + + +def create_audit(ctx: ClientContext, body: Dict[str, Any]) -> Dict[str, Any]: + """POST /api/audits — returns {id, created_at}.""" + return _post_json(ctx, "/api/audits", body) + + +def update_audit(ctx: ClientContext, audit_id: str, body: Dict[str, Any]) -> Dict[str, Any]: + """PUT /api/audits/{id} — full replace; returns {id, updated}.""" + return _put_json(ctx, f"/api/audits/{audit_id}", body) + + +def delete_audit(ctx: ClientContext, audit_id: str) -> None: + """DELETE /api/audits/{id}.""" + _delete(ctx, f"/api/audits/{audit_id}") + + +def run_audit(ctx: ClientContext, audit_id: str) -> Dict[str, Any]: + """POST /api/audits/{id}/run — queue a run now; 202 {queued: true} (409 if one is running).""" + return _post_json(ctx, f"/api/audits/{audit_id}/run") + + +def get_audit_context(ctx: ClientContext, audit_id: str) -> Dict[str, Any]: + """GET /api/audits/{id}/context — the brief plus each URL's snapshot state.""" + return _get_json(ctx, f"/api/audits/{audit_id}/context") + + +def put_audit_context(ctx: ClientContext, audit_id: str, body: Dict[str, Any]) -> Dict[str, Any]: + """PUT /api/audits/{id}/context — FULL REPLACEMENT; ``{"text":"","urls":[]}`` clears. + + A sub-resource rather than fields on the definition body, so a flag-only + ``audits edit`` — which read-merges through ``_audit_to_body``'s allowlist — + can never silently wipe it. + """ + return _put_json(ctx, f"/api/audits/{audit_id}/context", body) + + +def refresh_audit_context(ctx: ClientContext, audit_id: str) -> Dict[str, Any]: + """POST /api/audits/{id}/context/refresh — re-fetch every non-blocked URL.""" + return _post_json(ctx, f"/api/audits/{audit_id}/context/refresh") + + +def list_audit_runs(ctx: ClientContext, audit_id: str) -> List[AuditRun]: + """GET /api/audits/{id}/runs — run history, newest first (bare array).""" + data = _get_json(ctx, f"/api/audits/{audit_id}/runs") + return [AuditRun.from_dict(r) for r in (data if isinstance(data, list) else [])] + + +def list_audit_findings( + ctx: ClientContext, + *, + audit_id: Optional[str] = None, + run_id: Optional[str] = None, + status: Optional[Union[str, Sequence[str]]] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, +) -> List[AuditFinding]: + """GET /api/audits/findings — the org-wide triage list (bare array, priority-desc). + + ``status`` is CSV on the wire (server ``IN (...)``); omitting it leaves the server's + default live set (open + recurring). + """ + data = _get_json( + ctx, + "/api/audits/findings", + { + "audit_id": audit_id, + "run_id": run_id, + "status": _csv(status), + "limit": limit, + "offset": offset, + }, + ) + return [AuditFinding.from_dict(f) for f in (data if isinstance(data, list) else [])] + + +def get_audit_finding(ctx: ClientContext, finding_id: str) -> AuditFinding: + """GET /api/audits/findings/{fid} — one finding.""" + return AuditFinding.from_dict(_get_json(ctx, f"/api/audits/findings/{finding_id}")) + + +def set_finding_status( + ctx: ClientContext, + finding_id: str, + *, + action: str, + reason: Optional[str] = None, + assigned_to: Optional[str] = None, +) -> Dict[str, Any]: + """POST /api/audits/findings/{fid}/status — triage action; returns {id, action, ok}.""" + body: Dict[str, Any] = {"action": action} + if reason is not None: + body["reason"] = reason + if assigned_to is not None: + body["assigned_to"] = assigned_to + return _post_json(ctx, f"/api/audits/findings/{finding_id}/status", body) + + +# --- Agent assistant -------------------------------------------------------- + + +def agent_health(ctx: ClientContext) -> Dict[str, Any]: + """GET /api/agent/health — {enabled, llm_configured?, model?, models?, default_model?}.""" + data = _get_json(ctx, "/api/agent/health") + return data if isinstance(data, dict) else {} + + +def list_conversations(ctx: ClientContext) -> List[Dict[str, Any]]: + """GET /api/agent/conversations — {conversations:[...]}.""" + data = _get_json(ctx, "/api/agent/conversations") + return data.get("conversations", []) if isinstance(data, dict) else [] + + +def get_conversation(ctx: ClientContext, conversation_id: str) -> Dict[str, Any]: + """GET /api/agent/conversations/{id} — {title, messages:[...]}.""" + data = _get_json(ctx, f"/api/agent/conversations/{conversation_id}") + return data if isinstance(data, dict) else {} + + +def rename_conversation(ctx: ClientContext, conversation_id: str, title: str) -> None: + """PATCH /api/agent/conversations/{id}.""" + _patch_json(ctx, f"/api/agent/conversations/{conversation_id}", {"title": title}) + + +def delete_conversation(ctx: ClientContext, conversation_id: str) -> None: + """DELETE /api/agent/conversations/{id}.""" + _delete(ctx, f"/api/agent/conversations/{conversation_id}") + + +def create_conversation(ctx: ClientContext, title: str = "") -> Dict[str, Any]: + """POST /api/agent/conversations — create an empty conversation (owner = your email, + so it appears in the dashboard's assistant). Returns the created summary, incl. ``id``. + An empty title becomes the server default ("New conversation").""" + data = _post_json(ctx, "/api/agent/conversations", {"title": title}) + return data if isinstance(data, dict) else {} + + +def replace_messages( + ctx: ClientContext, conversation_id: str, messages: List[Dict[str, Any]] +) -> None: + """PUT /api/agent/conversations/{id}/messages — atomically replace the whole thread. + + ``messages`` use the shared wire shape ``{"role": ..., "content": {"text": ...}}`` so + the persisted transcript renders identically in the CLI and the dashboard assistant. + """ + _put_json(ctx, f"/api/agent/conversations/{conversation_id}/messages", {"messages": messages}) + + +def _stream_sse(ctx: ClientContext, path: str, body: Dict[str, Any]) -> Iterator[Dict[str, Any]]: + """POST and yield each parsed JSON object from an SSE ``data:`` stream.""" + # Keep the connect (and write/pool) timeout so an unreachable server still fails + # fast, but DISABLE the read timeout: an SSE answer can legitimately pause between + # frames (a slow LLM turn or a long tool call) far longer than --timeout, and a + # read-timeout there would kill the stream mid-answer and be mislabeled "cannot reach". + stream_timeout = httpx.Timeout(ctx.timeout, read=None) + url = _path(ctx, path) # chokepoint 4 of 4 for the /api -> /v1 rewrite + try: + with _client(ctx, timeout=stream_timeout) as client: + with client.stream("POST", url, json=body) as response: + if response.status_code >= 400: + response.read() + _raise_for_status(response, ctx) + buf = "" + for chunk in response.iter_text(): + buf += chunk + while "\n\n" in buf: + frame, buf = buf.split("\n\n", 1) + for line in frame.splitlines(): + if line.startswith("data:"): + payload = line[len("data:"):].strip() + if payload: + try: + yield _json.loads(payload) + except ValueError: + pass + except httpx.RequestError as exc: + raise NetworkError(f"Cannot reach FailproofAI Cloud at {ctx.base_url}: {exc}") + + +def agent_chat_oneshot( + ctx: ClientContext, + *, + message: Optional[str] = None, + messages: Optional[List[Dict[str, Any]]] = None, + conversation_id: Optional[str] = None, + page_context: Optional[str] = None, + model: Optional[str] = None, +) -> Dict[str, Any]: + """POST /api/agent/chat — accumulate the streamed answer. + + Pass a single ``message`` (a standalone turn) OR a full ``messages`` thread (to + continue a conversation); ``conversation_id`` is forwarded for correlation. Aborts + (``interrupted``) if the assistant asks for interactive input, since the CLI can't + hold an interactive turn. + """ + body: Dict[str, Any] = { + "messages": messages + if messages is not None + else [{"role": "user", "content": {"text": message}}] + } + if conversation_id: + body["conversationId"] = conversation_id + if page_context: + body["pageContext"] = page_context + if model: + body["model"] = model + parts: List[str] = [] + tools: List[str] = [] + interrupted = False + error: Optional[str] = None + for ev in _stream_sse(ctx, "/api/agent/chat", body): + kind = ev.get("type") + if kind == "text-delta": + parts.append(str(ev.get("text", ""))) + elif kind == "tool-start": + tools.append(str(ev.get("tool", ""))) + elif kind == "ask-user": + interrupted = True + error = str(ev.get("question") or "the assistant needs interactive input") + break + elif kind == "error": + error = str(ev.get("message", "assistant error")) + elif kind == "done": + break + return {"answer": "".join(parts), "tools": tools, "interrupted": interrupted, "error": error} + + +# --- Pagination helper ------------------------------------------------------ + + +def paginate( + fetch_page: Callable[..., Page], + *, + limit: Optional[int] = None, + page_size: Optional[int] = None, + start_cursor: Optional[Union[int, str]] = None, +) -> Iterator[Any]: + """Walk cursor pages, yielding items until exhausted or ``limit`` reached. + + ``fetch_page`` must accept ``cursor`` and ``limit`` keyword arguments and + return a :class:`Page`. Stops if the cursor fails to decrease (defensive + against a server that returns a non-decreasing cursor). + """ + if limit is not None and limit <= 0: + return + remaining = limit + cursor: Optional[Union[int, str]] = start_cursor + seen: set = set() + while True: + size = page_size or MAX_PAGE_SIZE + if remaining is not None: + size = min(size, remaining) + size = max(1, min(size, MAX_PAGE_SIZE)) + + page = fetch_page(cursor=cursor, limit=size) + for item in page.items: + yield item + if remaining is not None: + remaining -= 1 + if remaining <= 0: + return + + next_cursor = page.next_cursor + if next_cursor is None: + return + # Defensive loop guard: stop if the server hands back a cursor we've already + # walked (no forward progress). Keyed on the string form so it works for BOTH + # int cursors (sessions/evaluations) and string cursors (events) without comparing + # across types — the old `next_cursor >= cursor` crashed on a str/int mix when + # `--cursor` (a string) was combined with an int-cursor endpoint. + key = str(next_cursor) + if key in seen: + return + seen.add(key) + cursor = next_cursor + + +# ── Cloud-managed enforcement ──────────────────────────────────────────────── +# +# Every path here is ROOT-ONLY on the server: deliberately absent from `/v1`, +# because `/v1` is published on the dashboard host by the ingress and these are +# operator WRITE paths (publish, deploy, rollback). The commands therefore refuse +# API-key mode up front via `deny_in_key_mode` rather than translating a path +# that would 404 — see `server/src/routes/mod.rs`, the ROOT-ONLY block. + + +def list_policies(ctx: ClientContext) -> List[PolicyVersion]: + """GET /api/enforcement/policies — every published policy, latest version each.""" + data = _get_json(ctx, "/api/enforcement/policies") + items = data if isinstance(data, list) else data.get("policies", []) + return [PolicyVersion.from_dict(p) for p in items] + + +def publish_policy( + ctx: ClientContext, policy_id: str, source: str, description: str = "" +) -> PolicyVersion: + """POST /api/enforcement/policies — mints a NEW VERSION; never edits in place.""" + body = {"id": policy_id, "source": source, "description": description} + return PolicyVersion.from_dict(_post_json(ctx, "/api/enforcement/policies", body) or {}) + + +def set_policy_enabled(ctx: ClientContext, policy_id: str, enabled: bool) -> Dict[str, Any]: + """POST /api/enforcement/policies/{id}/{enable|disable}.""" + verb = "enable" if enabled else "disable" + path = f"/api/enforcement/policies/{policy_id}/{verb}" + return _post_json(ctx, path) or {} + + +def delete_policy(ctx: ClientContext, policy_id: str) -> Dict[str, Any]: + """DELETE /api/enforcement/policies/{id} — archives it; machines keep what they hold.""" + return _request_json(ctx, "DELETE", f"/api/enforcement/policies/{policy_id}") or {} + + +def list_machines(ctx: ClientContext) -> List[Machine]: + """GET /api/enforcement/machines — every host that has ever checked in.""" + data = _get_json(ctx, "/api/enforcement/machines") + items = data if isinstance(data, list) else data.get("machines", []) + return [Machine.from_dict(m) for m in items] + + +def rename_machine(ctx: ClientContext, machine_id: str, label: str) -> Dict[str, Any]: + """PATCH /api/enforcement/machines/{id} — a human label, not the id.""" + path = f"/api/enforcement/machines/{machine_id}" + return _request_json(ctx, "PATCH", path, json_body={"label": label}) or {} + + +def list_deployments(ctx: ClientContext) -> List[Deployment]: + """GET /api/enforcement/deployments — what every machine is told to run.""" + data = _get_json(ctx, "/api/enforcement/deployments") + items = data if isinstance(data, list) else data.get("deployments", []) + return [Deployment.from_dict(d) for d in items] + + +def get_deployment(ctx: ClientContext, machine_id: str) -> Optional[Deployment]: + """One machine's deployment, or None when nothing has been deployed to it. + + The read half of every read-modify-write. `deploy` is a FULL REPLACE, so a + caller that skips this and sends only what it wants ADDED silently removes + everything else. + """ + for dep in list_deployments(ctx): + if dep.machine_id == machine_id: + return dep + return None + + +def deploy_policies( + ctx: ClientContext, machine_id: str, policies: Sequence[PolicyRef] +) -> Deployment: + """PUT /api/enforcement/deployments/{id} — REPLACES the machine's whole set.""" + path = f"/api/enforcement/deployments/{machine_id}" + body = {"policies": [p.to_dict() for p in policies]} + return Deployment.from_dict(_request_json(ctx, "PUT", path, json_body=body) or {}) + + +def deployment_history(ctx: ClientContext, machine_id: str) -> List[Dict[str, Any]]: + """GET /api/enforcement/deployments/{id}/history — every generation, newest first.""" + path = f"/api/enforcement/deployments/{machine_id}/history" + data = _get_json(ctx, path) + return data if isinstance(data, list) else data.get("history", []) + + +def rollback_deployment(ctx: ClientContext, machine_id: str, deployment: int) -> Deployment: + """POST /api/enforcement/deployments/{id}/rollback — reinstate a past generation. + + Note this mints a NEW generation carrying the old set rather than rewinding + the counter, so the history stays append-only. + """ + path = f"/api/enforcement/deployments/{machine_id}/rollback" + body = {"deployment": deployment} + return Deployment.from_dict(_post_json(ctx, path, body) or {}) + + +def enforcement_summary( + ctx: ClientContext, hours: int = 24, machine_id: Optional[str] = None +) -> Dict[str, Any]: + """GET /api/enforcement/summary — coverage from Postgres, decisions from ClickHouse.""" + params = {"hours": hours} + if machine_id: + params["machineId"] = machine_id + return _get_json(ctx, "/api/enforcement/summary", params=params) or {} + + +def decision_timeline( + ctx: ClientContext, hours: int = 24, machine_id: Optional[str] = None +) -> Dict[str, Any]: + """GET /api/enforcement/decisions/timeline — hourly deny/instruct/paused bins.""" + params = {"hours": hours} + if machine_id: + params["machineId"] = machine_id + return _get_json(ctx, "/api/enforcement/decisions/timeline", params=params) or {} + + +def compose_policy(ctx: ClientContext, intent: str) -> Dict[str, Any]: + """POST /api/agent/compose-policy — the assistant drafts a policy source. + + STREAMS. The route answers `text/event-stream`, not JSON: `delta` frames as + tokens arrive, then one `done` carrying the finished source (the dashboard + feeds those deltas into a Monaco diff). Reading it as JSON gets a parse + error on the first frame, which is how this was written the first time. + + The field is `intent`, not `prompt` — the server rejects anything else with + a 400 before the model is ever called. + + Dashboard-only, like the rest of the assistant: there is no `/v1` route + behind it. + """ + source = "" + for event in _stream_sse(ctx, "/api/agent/compose-policy", {"intent": intent}): + kind = event.get("type") + if kind == "error": + raise ApiError( + str(event.get("reason") or "the policy composer hit an error"), + hint="check `fp agent health` — the assistant may not be configured here", + ) + if kind == "done": + source = str(event.get("source") or "") + return {"source": source, "usage": event.get("usage") or {}} + # The stream ended without a `done`. Returning "" here would render as an + # empty draft; saying so is the difference between a bug and a blank file. + # + # The overwhelmingly likely cause is the composer's own 30s ceiling — + # `agent/src/server.ts` aborts the request at 30_000ms, server-side, and a + # slower model or a longer intent simply does not finish. Naming it matters + # because the obvious remedy (raise --timeout) does nothing: the cut is not + # on this side. + raise ApiError( + "the assistant stopped before returning a policy — the composer has a " + "30s server-side limit and this draft did not finish inside it", + hint="try a shorter, more specific description, or run it again", + ) diff --git a/fp-cli/fp_cli/commands/__init__.py b/fp-cli/fp_cli/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fp-cli/fp_cli/commands/_write.py b/fp-cli/fp_cli/commands/_write.py new file mode 100644 index 000000000..c8e5fe579 --- /dev/null +++ b/fp-cli/fp_cli/commands/_write.py @@ -0,0 +1,214 @@ +"""Shared helpers for write/mutation commands (create / update / delete / …). + +Centralising these guarantees every domain behaves the same way: + * request bodies come from discrete flags **or** ``--file``/stdin JSON, + * mutations confirm before acting but never block a scripted/`--json` run, + * created/updated resources render identically, + * each action emits a privacy-safe telemetry event. +""" + +from __future__ import annotations + +import dataclasses +import json +import sys +from pathlib import Path +from typing import Any, Dict, Optional + +import typer + +from .. import _click_compat as click # the Click Typer is running; see _click_compat +from .. import analytics, output +from .._context import AppState +from ..errors import NotFoundError + +# Telemetry properties a write action may carry. Names/enums/coarse counts only — +# never ids, emails, names, URLs, SQL, key plaintext, or any customer value. +# A count is named `_count`, the form the dashboard and the API's own fields +# use, so the same quantity queries as one series across surfaces. Bare `count` is the +# older spelling, kept only so anything still sending it lands rather than vanishing. +_SAFE_PROP_KEYS = frozenset( + {"success", "resource", "via", "mode", "destructive", "row_count_bucket", + "permission_count", "url_count", "trigger_kind", "severity_present", "count"} +) + + +def resolve_one( + items, + handle: str, + *, + kind: str, + list_cmd: str, + key: str = "name", + ref: str = "named", + match_id: bool = True, + plural: Optional[str] = None, + casefold: bool = False, +): + """Find the single ``items`` entry whose ``key`` (or, as a fallback, ``id``) equals ``handle``. + + The one place name/email→object resolution lives, so every command fails identically: + * exactly one match → return it, + * none → ``raise NotFoundError`` (exit 6) with a "run `fp `" hint, + * several → ``raise click.UsageError`` (exit 2) telling the caller to use the id. + + The single error chokepoint (app.py) renders the raised exception — JSON envelope under + ``--json`` (stdout), red box otherwise — so callers never branch on ``state.json`` themselves. + + ``casefold`` compares case-insensitively. Opt-in rather than the default because it is only + correct where the server itself normalises: emails are lowercased on create, so + ``fp users create Alice.Chen@Example.com`` stores ``alice.chen@example.com`` and every + later ``fp users show Alice.Chen@Example.com`` answered "no user with email" — the CLI + denying a member it had just created, with the exact string the caller had just typed. + Key and query names are stored verbatim, so folding them would let ``PROD`` resolve + ``prod`` and silently act on the wrong object. + """ + + def norm(value): + return value.casefold() if casefold and isinstance(value, str) else value + + target = norm(handle) + matches = [it for it in items if norm(getattr(it, key, None)) == target] + if not matches and match_id: + matches = [it for it in items if str(getattr(it, "id", "")) == handle] + if len(matches) == 1: + return matches[0] + if not matches: + raise NotFoundError( + f'no {kind} {ref} "{handle}"', hint=f"run `fp {list_cmd}` to list them" + ) + raise click.UsageError( + f'"{handle}" matches {len(matches)} {plural or kind + "s"} — disambiguate with the id.' + ) + + +def read_text_arg(path: str, *, flag: str = "--file") -> str: + """Read a file's text (or stdin when ``path == "-"``), mapping a missing/unreadable + path to a clean usage error (exit 2) instead of a raw OSError traceback. Shared by + every ``--file`` / ``@file`` reader so they all fail the same, predictable way.""" + if path == "-": + return sys.stdin.read() + try: + return Path(path).read_text() + except OSError as exc: + raise click.BadParameter( + f"cannot read '{path}': {exc.strerror or exc}", param_hint=flag + ) + + +def read_body( + state: AppState, + file: Optional[str], + inline: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Resolve a request body. + + ``--file PATH`` (or ``--file -`` for stdin) JSON wins and must be a JSON object; + otherwise the discrete-flag ``inline`` dict is used (``None`` values dropped, so a + partial PATCH/PUT body stays clean). Giving both a populated ``--file`` and discrete + flags is a usage error. + """ + cleaned_inline = {k: v for k, v in (inline or {}).items() if v is not None} + if file is None: + return cleaned_inline + raw = read_text_arg(file) + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise click.BadParameter(f"--file is not valid JSON: {exc}", param_hint="--file") + if not isinstance(parsed, dict): + raise click.BadParameter("--file must contain a JSON object.", param_hint="--file") + if cleaned_inline: + raise click.UsageError("Pass either --file or the discrete flags, not both.") + return parsed + + +def confirm(state: AppState, action: str, *, assume_yes: bool, destructive: bool = False) -> None: + """Confirm a mutation, unless it is safe to skip. + + Skips the prompt when ``--yes`` is given, ``--json`` is set, or stdin is not a TTY + (all mean a script/agent is driving and a blocking prompt would hang). Otherwise + prompts and aborts (exit 1) on "no". + """ + if assume_yes or state.json or not sys.stdin.isatty(): + return + if destructive: + output.warn(f"This cannot be undone: {action}.") + typer.confirm(f"{action}?", default=False, abort=True, err=True) + + +def should_prompt(state: AppState, assume_yes: bool) -> bool: + """Whether to actually show an interactive confirm. ``False`` (auto-proceed) when ``--yes`` + is given, ``--json`` is set, or stdin is not a TTY — all mean a script/agent is driving and + a blocking prompt would hang. Lets a caller render a bespoke confirm (e.g. the users-update + diff prompt) behind the same guard the shared helpers use.""" + return not (assume_yes or state.json or not sys.stdin.isatty()) + + +def confirm_action( + state: AppState, action: str, target: str, *, consequence: str, assume_yes: bool, + glyph: str = "⚠", color: Optional[str] = None, title: str = "confirm", +) -> bool: + """Confirm a named action. Returns ``True`` to proceed, ``False`` if the user declined. + + Proceeds without prompting under :func:`should_prompt`'s conditions. Otherwise shows the + shared boxed prompt (default NO) via ``output.confirm_prompt`` — amber ⚠ by default + (destructive), or a caller-supplied glyph/colour (e.g. the calm ACCENT ``↑`` for + ``users enable``). + """ + if not should_prompt(state, assume_yes): + return True + return output.confirm_prompt(action, target, consequence, glyph=glyph, color=color, title=title) + + +def confirm_destructive( + state: AppState, action: str, target: str, *, consequence: str, assume_yes: bool +) -> bool: + """Confirm a destructive, named action (regenerate / disable / revoke a key) with the shared + amber ⚠ prompt. Thin wrapper over :func:`confirm_action` with the destructive defaults.""" + return confirm_action(state, action, target, consequence=consequence, assume_yes=assume_yes) + + +def _to_dict(resource: Any) -> Any: + if dataclasses.is_dataclass(resource) and not isinstance(resource, type): + return dataclasses.asdict(resource) + return resource + + +def emit_resource( + state: AppState, + resource: Any, + *, + action: str, + title: str, + summary_fields=None, +) -> None: + """Render the result of a mutation. + + ``--json``: the resource verbatim. Human: a green success line plus a field/value + table of ``summary_fields`` (or all fields when omitted). + """ + data = _to_dict(resource) + if state.json: + output.emit_json(data) + return + output.success(f"{title} {action}.") + if isinstance(data, dict): + keys = summary_fields if summary_fields is not None else list(data.keys()) + rows = [[k, _cell(data.get(k))] for k in keys] + if rows: + output.print_table(["Field", "Value"], rows) + + +def _cell(value: Any) -> str: + if value is None: + return "-" + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False) + return str(value) + + +def record_action(event: str, **props: Any) -> None: + """Emit a privacy-safe per-action telemetry event (allowlisted properties only).""" + safe = {k: v for k, v in props.items() if k in _SAFE_PROP_KEYS} + analytics.capture(event, safe) diff --git a/fp-cli/fp_cli/commands/agent_cmds.py b/fp-cli/fp_cli/commands/agent_cmds.py new file mode 100644 index 000000000..476fff150 --- /dev/null +++ b/fp-cli/fp_cli/commands/agent_cmds.py @@ -0,0 +1,407 @@ +"""FailproofAI Cloud assistant (Claude) from the CLI: agent health / models / chats / ask / show / rename / delete. + +Every ``ask`` is saved to a **chat**: with ``--chat `` it continues that chat, without it a +new chat is started and its short id printed (so you can continue it). ``chats`` lists them; +``show`` / ``rename`` / ``delete`` manage one; ``models`` lists the models you can pass to +``ask --model``. + +Chats are referenced by a **short chat-id** — the first 8 chars (the segment before the UUID's +first ``-``) shown in ``agent chats``. The CLI resolves that prefix back to the full chat against +your chat list (so the server/dashboard keep the full id intact); a prefix that matches none → a +``chat not found`` box, one that matches several → an ``ambiguous`` box. If the assistant asks for +interactive input mid-turn, ``ask`` aborts cleanly rather than hang. +""" + +from __future__ import annotations + +import sys +from typing import Optional + +import typer + +from .. import _click_compat as click # the Click Typer is running; see _click_compat +from .. import client as api +from .. import output +from .._context import GLOBALS_EPILOG, AppState, deny_in_key_mode, require_auth +from ..errors import NotFoundError +from . import _write + +# The assistant has NO server route at all: `/agent/chat` and `/agent/health` are +# implemented by the dashboard itself, and the conversation routes are per-human +# (keyed by actor) and deliberately excluded from the versioned API. So there is +# nothing for an API key to call here — refuse before opening a connection. +_KEY_MODE_REASON = ( + "the assistant runs in the dashboard and its chats belong to a person — there is " + "no API route behind it for a key to call" +) + +# The server's default title for a freshly-created chat; we auto-title from the first +# question only while the title is still this (so an explicit one is kept). +_DEFAULT_TITLE = "New conversation" + + +def _text_of(content: object) -> str: + """Extract the display text from a stored message ``content`` — mirrors the + dashboard's `textOf` so CLI and dashboard render the same thread identically.""" + if isinstance(content, str): + return content + if isinstance(content, dict) and isinstance(content.get("text"), str): + return content["text"] + return "" + + +def _title_from_question(question: str) -> str: + """A short, single-line chat title derived from the first question.""" + collapsed = " ".join(question.split()) + if not collapsed: + return _DEFAULT_TITLE + return collapsed[:57] + "…" if len(collapsed) > 60 else collapsed + + +def _models_of(health: dict) -> list: + """The model allowlist from an agent-health payload (handles list or single shapes).""" + models = health.get("models") + if isinstance(models, list) and models: + return [str(m) for m in models] + one = health.get("default_model") or health.get("defaultModel") or health.get("model") + return [str(one)] if one else [] + + +def _default_model_of(health: dict) -> Optional[str]: + return health.get("default_model") or health.get("defaultModel") or health.get("model") + + +def _is_configured(health: dict) -> bool: + """Whether the assistant is usable on this deployment — ``enabled`` AND (``llm_configured`` + when the server reports it; absent → assume configured if enabled).""" + enabled = bool(health.get("enabled")) + llm = health.get("llm_configured") + return enabled and (llm if isinstance(llm, bool) else True) + + +def _validate_model(cctx, model: str) -> None: + """Reject a ``--model`` that isn't on the deployment's allowlist. Skips the check when + the deployment reports no models (lets the server decide / fall back).""" + models = _models_of(api.agent_health(cctx)) + if models and model not in models: + raise typer.BadParameter( + f"model not found {model!r}; available: {', '.join(models)} (see `fp agent models`).", + param_hint="--model", + ) + + +def _resolve_chat_or_exit(state: AppState, cctx, handle: str) -> dict: + """Resolve a chat by its short id PREFIX (the first-8 ``chat-id`` shown in ``agent chats``) or + a full id, matching against the chat list. Returns the matched chat summary (full ``id`` + + ``title`` + ``message_count``). None → boxed ``chat not found`` (exit 6); more than one → boxed + ``ambiguous`` (exit 2). Resolving via the list also avoids fetching a non-existent id directly + (which the server answers with a 500). Shared by show / rename / delete / ask --chat.""" + chats = api.list_conversations(cctx) + exact = [c for c in chats if str(c.get("id")) == handle] + matches = exact or [c for c in chats if str(c.get("id", "")).startswith(handle)] + if len(matches) == 1: + return matches[0] + if not matches: + raise NotFoundError( + f"chat not found: {handle}", hint="run `fp agent chats` to list them" + ) + ids = [str(c.get("id")) for c in matches] + raise click.UsageError( + f"ambiguous chat id: {handle} — matches {len(ids)}; use a longer prefix or the full id" + ) + + +def agent_health(ctx: typer.Context) -> None: + """Show whether the assistant is configured on this deployment. + + Needs `agent:use`. With `--json`: `{enabled, llm_configured?, model?, models?, default_model?}`. + + Example: + + * `fp --json agent health` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "agent", _KEY_MODE_REASON) + cctx = require_auth(state) + data = api.agent_health(cctx) + if state.json: + output.emit_json(data) + return + output.render_agent_health( + configured=_is_configured(data), + default_model=_default_model_of(data), + model_count=len(_models_of(data)), + ) + + +def agent_models(ctx: typer.Context) -> None: + """List the assistant models available on this deployment. + + Needs `agent:use`. The choices (and the default) come from the agent service's + allowlist; pass any of them to `agent ask --model `. With `--json`: + `{"models": [...], "default_model": "..."}`. + + Example: + + * `fp agent models` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "agent", _KEY_MODE_REASON) + cctx = require_auth(state) + data = api.agent_health(cctx) + models = _models_of(data) + default = _default_model_of(data) + if state.json: + output.emit_json({"models": models, "default_model": default}) + return + output.render_agent_models(models, default_model=default) + if not models: + output.agent_unconfigured_note() + + +def agent_chats( + ctx: typer.Context, +) -> None: + """List your saved chats in a boxed table, newest first. + + Shows `chat-id · title · messages · updated` — `chat-id` is the SHORT id (the first 8 chars; a + prefix the `agent show`/`rename`/`delete`/`ask --chat` commands resolve), and `updated` is the + chat's last-activity age. Needs `agent:use`. With `--json`: `{"chats": [{id, title, updated_at, + message_count}]}` (full id). + + Example: + + * `fp agent chats` + * `fp --json agent chats` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "agent", _KEY_MODE_REASON) + cctx = require_auth(state) + chats = api.list_conversations(cctx) + if state.json: + output.emit_json({"chats": chats}) + return + output.render_agent_chats(chats) + + +def agent_show( + ctx: typer.Context, + chat_id: str = typer.Argument(..., metavar="CHAT_ID", help="Chat id — the short one from `agent chats` (a prefix) or a full id."), +) -> None: + """Show a chat as a readable thread — its title, then each turn (`you` / `assistant`). + + Pass the **short chat-id** from `agent chats` (the first 8 chars; the CLI resolves the prefix + to the full chat) or a full id. Not-found → a `chat not found` error box, exit 6. Needs + `agent:use`. With `--json`: `{title, messages: [...]}`. + + Example: + + * `fp agent show 07854990` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "agent", _KEY_MODE_REASON) + cctx = require_auth(state) + chat = _resolve_chat_or_exit(state, cctx, chat_id) # short prefix → the full chat + data = api.get_conversation(cctx, chat["id"]) + if state.json: + output.emit_json(data) + return + output.render_agent_show(title=data.get("title"), messages=data.get("messages", []) or [], chat_id=chat["id"]) + + +def agent_rename( + ctx: typer.Context, + chat_id: str = typer.Argument(..., metavar="CHAT_ID", help="Chat id (short prefix or full)."), + title: str = typer.Option(..., "--title", help="New title for the chat."), +) -> None: + """Rename a chat, referenced by its **short chat-id** (a prefix) or full id. + + Not-found → a `chat not found` error box, exit 6. Needs `agent:use`. With `--json`: + `{"id": "", "title": ""}`. + + Example: + + * `fp agent rename 07854990 --title "page walkthrough"` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "agent", _KEY_MODE_REASON) + cctx = require_auth(state) + chat = _resolve_chat_or_exit(state, cctx, chat_id) # validates + gives the full id + old title + old_title = chat.get("title") + api.rename_conversation(cctx, chat["id"], title) + _write.record_action("agent_chat_renamed", resource="conversation", success=True) + if state.json: + output.emit_json({"id": chat["id"], "title": title}) + else: + output.render_agent_renamed(chat_id=chat["id"], title=title, old_title=old_title) + + +def agent_delete( + ctx: typer.Context, + chat_id: str = typer.Argument(..., metavar="CHAT_ID", help="Chat id to delete (short prefix or full)."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Delete a chat, referenced by its **short chat-id** (a prefix) or full id. Cannot be undone. + + Shows an amber preview, then confirms. Not-found → a `chat not found` error box, exit 6. Needs + `agent:use`. With `--json`: `{"deleted": true, "id": "<full id>", "title": "<title>"}` (or + `{"cancelled": true}` on a declined prompt). + + Example: + + * `fp agent delete 07854990` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "agent", _KEY_MODE_REASON) + cctx = require_auth(state) + chat = _resolve_chat_or_exit(state, cctx, chat_id) # validates + powers the preview + title = chat.get("title") or chat["id"] + message_count = chat.get("message_count") or 0 + if _write.should_prompt(state, yes): + output.render_agent_delete_preview(title=title, message_count=message_count, chat_id=chat["id"]) + if not output.confirm_agent_delete(): + if state.json: + output.emit_json({"cancelled": True}) + else: + output.print_cancelled("nothing deleted") + return + api.delete_conversation(cctx, chat["id"]) + _write.record_action("agent_chat_deleted", resource="conversation", success=True, destructive=True) + if state.json: + output.emit_json({"deleted": True, "id": chat["id"], "title": title}) + else: + output.agent_deleted(title) + + +def agent_ask( + ctx: typer.Context, + message: Optional[str] = typer.Argument(None, metavar="MESSAGE", help="Your question (or pipe it on stdin)."), + chat: Optional[str] = typer.Option(None, "--chat", help="Continue this chat — its short id from `agent chats` (a prefix) or full id. Omit to start a NEW chat (its id is printed)."), + model: Optional[str] = typer.Option(None, "--model", help="Model to use (see `agent models`)."), + page_context: Optional[str] = typer.Option(None, "--page-context", help="Optional context string to ground the answer."), +) -> None: + """Ask the assistant a question — every ask is saved to a chat. + + Pass the question as the **positional MESSAGE** (or pipe it on stdin). Needs `agent:use`. + + * **No `--chat`** → starts a **new chat**, answers, persists, and prints the new chat's short + id (continue it with `--chat <short id>`); the first question auto-titles the chat. + * **`--chat <id>`** → continues that chat (short prefix or full id): its prior thread is sent + for context and this turn is appended. Chats also show up in the dashboard assistant. + + The answer renders in a boxed `assistant` card (interactively); when piped it's the raw answer + on **stdout** (a clean payload). A brand-new chat is created only once an answer lands — if the + assistant needs interactive input it aborts (exit 1) and leaves no empty chat. With `--json`: + `{answer, tools, interrupted, error, chat_id}`. + + Examples: + + * `fp agent ask "which agents errored most in the last day?"` + * `fp agent ask "and how about the last week?" --chat 07854990` + * `fp agent ask "summarize p95 latency" --model claude-opus-4-7` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "agent", _KEY_MODE_REASON) + if not message: + message = sys.stdin.read().strip() if not sys.stdin.isatty() else typer.prompt("question", err=True) + if not message: + raise typer.BadParameter("Provide a question as the MESSAGE argument or on stdin.") + cctx = require_auth(state) + if model is not None: + _validate_model(cctx, model) + + # Continuing a chat → resolve the (short) handle to a full id and load its prior thread; + # a new chat starts empty (and is created only on success, so a failed ask leaves no orphan). + full_chat_id: Optional[str] = None + prior = [] + if chat: + full_chat_id = _resolve_chat_or_exit(state, cctx, chat)["id"] + prior = api.get_conversation(cctx, full_chat_id).get("messages", []) or [] + thread = [ + {"role": str(m.get("role", "user")), "content": {"text": _text_of(m.get("content"))}} + for m in prior + ] + thread.append({"role": "user", "content": {"text": message}}) + + # The reply streams server-side (an LLM turn + tool calls can take many seconds); show a + # themed spinner on stderr so the CLI doesn't look hung. No-op under --json / non-TTY. + with output.thinking("thinking…", enabled=not state.json): + result = api.agent_chat_oneshot( + cctx, messages=thread, conversation_id=full_chat_id, page_context=page_context, model=model + ) + + chat_id = full_chat_id + if not result.get("interrupted") and not result.get("error"): + if chat_id is None: + # New chat: set the question-derived title at creation (one POST) instead of + # create-with-empty-title then a follow-up rename PATCH. + chat_id = (api.create_conversation(cctx, title=_title_from_question(message)) or {}).get("id") + if chat_id: + full = thread + [{"role": "assistant", "content": {"text": result.get("answer", "")}}] + api.replace_messages(cctx, chat_id, full) + if full_chat_id is not None and not prior: + # Continuing a chat that had no messages yet → title it from this first question + # (a brand-new chat is already titled at create_conversation above). + api.rename_conversation(cctx, chat_id, _title_from_question(message)) + + _write.record_action( + "agent_chat", resource="conversation", + success=not result.get("interrupted"), + mode="continue" if chat else "new", + ) + if state.json: + output.emit_json({**result, "chat_id": chat_id}) + else: + for tool in result.get("tools", []): + output.agent_tool_used(tool) + answer = result.get("answer", "") + if sys.stdout.isatty(): + output.render_agent_answer(answer, model=model) # boxed assistant card + else: + print(answer) # raw answer → stdout (pipeable) + if chat_id and not chat: # a fresh chat was created + output.render_agent_new_chat(chat_id) + if result.get("interrupted"): + output.agent_error(f"the assistant needs interactive input ({result.get('error')}); not supported in the CLI") + raise typer.Exit(code=1) + if result.get("error"): + output.agent_error(f"assistant error: {result.get('error')}") + raise typer.Exit(code=1) + + +_AGENT_GROUP_HELP = """Chat with the FailproofAI Cloud assistant (Claude) about your org's observability data. + +`ask` a question — each ask is saved to a **chat**. Continue a chat with `--chat <short id>` +(the first 8 chars shown in `agent chats`). The assistant can read your sessions, events, +evaluations, alerts, keys, and saved queries — scoped to your org. + +**Subcommands:** `health` · `models` · `chats` · `ask` · `show` · `rename` · `delete` + +**Examples:** + +* `fp agent health` — is the assistant configured on this deployment? +* `fp agent models` — which models can I pass to `ask --model`? +* `fp agent ask "which agents errored most in the last day?"` — ask (starts a new chat) +* `fp agent ask "and the last week?" --chat 07854990` — continue a chat by its short id +* `fp agent chats` — list your chats (with their short ids) +* `fp agent show 07854990` — read a chat's full thread +* `fp agent rename 07854990 --title "latency dig"` · `fp agent delete 07854990` +""" + + +def register(app: typer.Typer) -> None: + agent = typer.Typer( + no_args_is_help=True, + rich_markup_mode="markdown", + context_settings={"help_option_names": ["-h", "--help"]}, + help=_AGENT_GROUP_HELP, + ) + # Order: discover (health/models) → list (chats) → use (ask) → manage one (show/rename/delete). + agent.command("health", epilog=GLOBALS_EPILOG)(agent_health) + agent.command("models", epilog=GLOBALS_EPILOG)(agent_models) + agent.command("chats", epilog=GLOBALS_EPILOG)(agent_chats) + agent.command("ask", epilog=GLOBALS_EPILOG)(agent_ask) + agent.command("show", epilog=GLOBALS_EPILOG)(agent_show) + agent.command("rename", epilog=GLOBALS_EPILOG)(agent_rename) + agent.command("delete", epilog=GLOBALS_EPILOG)(agent_delete) + app.add_typer(agent, name="agent") diff --git a/fp-cli/fp_cli/commands/alerts_cmds.py b/fp-cli/fp_cli/commands/alerts_cmds.py new file mode 100644 index 000000000..245200318 --- /dev/null +++ b/fp-cli/fp_cli/commands/alerts_cmds.py @@ -0,0 +1,445 @@ +"""Alert definitions: alerts list / show / create / update / delete / test. + +Alerts are referenced by their **name** (unique per org). An alert is a **trigger** (a condition, +shaped per ``trigger_kind`` — e.g. a metric threshold, a custom SQL count, an evaluation-score +rule) plus an **evaluation** cadence and a set of notification **channels**. The trigger body +(``trigger_spec``) + ``channels`` are opaque/union JSON, so create/update take them as JSON flags +or a full payload via ``--file``/stdin, with a few common scalar fields available as convenience +overrides layered on top. +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, Optional + +import typer + +from .. import _click_compat as click # the Click Typer is running; see _click_compat +from .. import client as api +from .. import output +from .._context import GLOBALS_EPILOG, AppState, require_auth +from ..models import Alert +from . import _write + +_TRIGGER_KINDS = ("metric_threshold", "custom_sql", "evaluation_score", "eval_compound", "per_event") +_SEVERITIES = ("info", "warning", "critical") + + +def _load_file(file: Optional[str]) -> Dict[str, Any]: + if file is None: + return {} + raw = _write.read_text_arg(file) + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise typer.BadParameter(f"--file is not valid JSON: {exc}", param_hint="--file") + if not isinstance(parsed, dict): + raise typer.BadParameter("--file must contain a JSON object.", param_hint="--file") + return parsed + + +def _apply_overrides(body: Dict[str, Any], **overrides: Any) -> Dict[str, Any]: + for key, value in overrides.items(): + if value is not None: + body[key] = value + return body + + +def _alert_to_body(alert: Alert) -> Dict[str, Any]: + """The writable AlertBody fields of an existing alert, used as the merge base for + a flag-only update. + + The server's ``PUT /api/alerts/{id}`` is a **full replace** (it overwrites every + column), so a single-field edit must resend the whole alert. We seed the body from + the current alert and let the override flags change just the named fields — the + same read-merge ``users update`` already does.""" + return { + "name": alert.name, + "description": alert.description, + "enabled": alert.enabled, + "trigger_kind": alert.trigger_kind, + "trigger_spec": alert.trigger_spec, + "min_breaches": alert.min_breaches, + "eval_window": alert.eval_window, + "eval_interval_secs": alert.eval_interval_secs, + "severity": alert.severity, + "channels": alert.channels, + } + + +def _validate_alert(body: Dict[str, Any], *, require_core: bool) -> None: + if require_core: + if not body.get("name"): + raise typer.BadParameter("alert 'name' is required (in --file or via --name).") + if not body.get("trigger_kind"): + raise typer.BadParameter("alert 'trigger_kind' is required (in --file or via --trigger-kind).") + if body.get("trigger_spec") is None: + raise typer.BadParameter("alert 'trigger_spec' is required (provide it in --file or --trigger-spec).") + if body.get("trigger_kind") and body["trigger_kind"] not in _TRIGGER_KINDS: + raise typer.BadParameter(f"trigger_kind must be one of: {', '.join(_TRIGGER_KINDS)}.") + if body.get("severity") and body["severity"] not in _SEVERITIES: + raise typer.BadParameter(f"severity must be one of: {', '.join(_SEVERITIES)}.") + mb, ew = body.get("min_breaches"), body.get("eval_window") + if isinstance(mb, int) and mb < 1: + raise typer.BadParameter("min_breaches must be >= 1.") + if isinstance(ew, int) and ew < 1: + raise typer.BadParameter("eval_window must be >= 1.") + if isinstance(mb, int) and isinstance(ew, int) and mb > ew: + raise typer.BadParameter("min_breaches cannot exceed eval_window.") + eis = body.get("eval_interval_secs") + if isinstance(eis, int) and not (30 <= eis <= 86400): + raise typer.BadParameter("eval_interval_secs must be between 30 and 86400.") + + +def _parse_json_opt(value: Optional[str], hint: str) -> Any: + if value is None: + return None + try: + return json.loads(value) + except json.JSONDecodeError as exc: + raise typer.BadParameter(f"{hint} is not valid JSON: {exc}", param_hint=hint) + + +def _refetch_alert(cctx, name: Optional[str]) -> Optional[Alert]: + """Re-read the canonical stored alert by name after a create/update (the server busts the + list cache on write, so this reflects the saved state incl. server-applied defaults). Returns + ``None`` on any miss so the caller can fall back to rendering from the request body.""" + if not name: + return None + try: + for a in api.list_alerts(cctx): + if a.name == name: + return a + except Exception: + return None + return None + + +def _alert_from_body(body: Dict[str, Any], base: Optional[Alert] = None) -> Alert: + """Build an ``Alert`` view from a create/update request body (fallback when the canonical + re-fetch misses). ``base`` (the pre-update alert) supplies id/created/open-incidents context; + for a create there is none, so those default to empty/0.""" + return Alert( + id=base.id if base else "", + name=body.get("name") or (base.name if base else ""), + description=body.get("description") if body.get("description") is not None else (base.description if base else None), + enabled=body.get("enabled", base.enabled if base else True), + trigger_kind=body.get("trigger_kind") or (base.trigger_kind if base else ""), + trigger_spec=body.get("trigger_spec") or (base.trigger_spec if base else {}), + min_breaches=body.get("min_breaches", base.min_breaches if base else 1), + eval_window=body.get("eval_window", base.eval_window if base else 1), + eval_interval_secs=body.get("eval_interval_secs", base.eval_interval_secs if base else 0), + severity=body.get("severity") or (base.severity if base else ""), + channels=body.get("channels") if body.get("channels") is not None else (base.channels if base else []), + created_by=base.created_by if base else "", + created_at=base.created_at if base else "", + updated_at="", + last_attempted_at=base.last_attempted_at if base else None, + open_incidents=base.open_incidents if base else 0, + ) + + +def _resolve_alert_or_exit(state: AppState, alerts, handle: str) -> Alert: + """Resolve an alert by **name** (primary), falling back to an exact id match, raising a typed + error the central chokepoint renders: none → exit 6, several → exit 2.""" + return _write.resolve_one(alerts, handle, kind="alert", list_cmd="alerts list") + + +def alerts_list( + ctx: typer.Context, + show_id: bool = typer.Option(False, "--show-id", help="Prepend a short alert-id column (the full id is always in --json)."), +) -> None: + """List alert definitions in a boxed table, newest first. + + Shows `created · name · by · trigger · severity · last alert` — `by` the creator's email, + severity colour-coded, and `last alert` the humanized age of the last evaluation (`never` if it + has never run, e.g. a disabled alert). Disabled alerts are dimmed; the on/off split is in the + footer. `name` is the handle action commands take; the raw id is hidden unless `--show-id`. + Needs `alerts:read`. With `--json`: `{"alerts": [{id, name, created_by, trigger_kind, severity, + enabled, last_attempted_at, created_at, open_incidents, ...}]}`. + + Example: + + * `fp alerts list` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + alerts = api.list_alerts(cctx) + if state.json: + output.emit_json({"alerts": alerts}) + return + output.render_alerts(alerts, show_id=show_id) + output.alerts_footer(alerts) + + +def alerts_show( + ctx: typer.Context, + name: str = typer.Argument(..., metavar="NAME", help="Alert name (or id)."), +) -> None: + """Show one alert as a stack of parsed cards — identity, trigger, evaluation, channels. + + Referenced by alert **name** (a UUID-shaped id is also accepted). The `trigger_spec` is parsed + into a human sentence per `trigger_kind`, and `channels` shows default-vs-custom per channel. + Not-found → red `✗ no alert named "…"`, exit 6. Needs `alerts:read`. With `--json`: the full + raw `Alert` (untouched `trigger_spec` + `channels`). + + Example: + + * `fp alerts show metric-threshold-alert` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + alert = _resolve_alert_or_exit(state, api.list_alerts(cctx), name) + if state.json: + output.emit_json(alert) + return + output.render_alert_show(alert) + + +def _common_overrides(name, description, severity, trigger_kind, eval_interval_secs, min_breaches, eval_window, trigger_spec, channels): + return dict( + name=name, + description=description, + severity=severity, + trigger_kind=trigger_kind, + eval_interval_secs=eval_interval_secs, + min_breaches=min_breaches, + eval_window=eval_window, + trigger_spec=_parse_json_opt(trigger_spec, "--trigger-spec"), + channels=_parse_json_opt(channels, "--channels"), + ) + + +def alerts_create( + ctx: typer.Context, + name: str = typer.Argument(..., metavar="NAME", help="Alert name (unique per org)."), + file: Optional[str] = typer.Option(None, "--file", help="Full alert JSON (AlertInput) to base it on, or `-` for stdin."), + description: Optional[str] = typer.Option(None, "--description", help="Description (overrides --file)."), + severity: Optional[str] = typer.Option(None, "--severity", help=f"Severity: {', '.join(_SEVERITIES)} (overrides --file)."), + trigger_kind: Optional[str] = typer.Option(None, "--trigger-kind", help=f"What kind of trigger: {', '.join(_TRIGGER_KINDS)} (overrides --file)."), + trigger_spec: Optional[str] = typer.Option(None, "--trigger-spec", help="Trigger spec as JSON — the condition, shaped per trigger kind (overrides --file)."), + channels: Optional[str] = typer.Option(None, "--channels", help="Channels as a JSON array, e.g. `[{\"kind\":\"email\"}]` (overrides --file)."), + eval_interval_secs: Optional[int] = typer.Option(None, "--eval-interval-secs", help="How often to evaluate, in seconds (30–86400)."), + min_breaches: Optional[int] = typer.Option(None, "--min-breaches", help="Breaches within the window required to fire."), + eval_window: Optional[int] = typer.Option(None, "--eval-window", help="Evaluation window size (number of intervals)."), +) -> None: + """Create an alert and show it rendered as parsed cards. + + Give the alert a **name** (positional), then define it inline with the flags or base it on a + full JSON payload via `--file` (flags layer on top). The core pieces are `--trigger-kind` + + `--trigger-spec` (the condition) and `--severity`; the rest have sensible server defaults. On + success the new alert renders the same way `alerts show` does (identity + trigger + evaluation + + channels) in a green "alert created" card. Creating isn't destructive, so there's no confirm. + A name collision is rejected up front. New alerts start **enabled**. Needs `alerts:write`. With + `--json`: `{id, created_at}`. + + Examples: + + * `fp alerts create high-errors --trigger-kind metric_threshold --severity warning \\ + --trigger-spec '{"metric":"error_count","op":">","value":50,"window_secs":900}'` + * `fp alerts create high-errors --file alert.json` — base it on a saved AlertInput + * `fp alerts create high-errors --file alert.json --severity critical` — file + an override + """ + state: AppState = ctx.obj + body = _load_file(file) + _apply_overrides(body, **_common_overrides(name, description, severity, trigger_kind, eval_interval_secs, min_breaches, eval_window, trigger_spec, channels)) + _validate_alert(body, require_core=True) + cctx = require_auth(state) + if any(a.name == body.get("name") for a in api.list_alerts(cctx)): # names are the handle → unique + raise click.UsageError(f'an alert named "{body.get("name")}" already exists') + result = api.create_alert(cctx, body) + _write.record_action("alert_created", resource="alert", success=True, trigger_kind=body.get("trigger_kind")) + if state.json: + output.emit_json(result) + return + # Re-read the canonical stored alert (server defaults applied) for the rendered cards. + alert = _refetch_alert(cctx, body.get("name")) or _alert_from_body(body) + output.render_alert_created(alert) + + +def alerts_update( + ctx: typer.Context, + name: str = typer.Argument(..., metavar="NAME", help="Alert name (or id) to update."), + new_name: Optional[str] = typer.Option(None, "--name", help="Rename the alert."), + file: Optional[str] = typer.Option(None, "--file", help="Full alert JSON (AlertInput) to replace it with, or `-` for stdin."), + description: Optional[str] = typer.Option(None, "--description", help="New description."), + severity: Optional[str] = typer.Option(None, "--severity", help=f"New severity: {', '.join(_SEVERITIES)}."), + trigger_kind: Optional[str] = typer.Option(None, "--trigger-kind", help=f"New trigger kind: {', '.join(_TRIGGER_KINDS)}."), + trigger_spec: Optional[str] = typer.Option(None, "--trigger-spec", help="New trigger spec as JSON."), + channels: Optional[str] = typer.Option(None, "--channels", help="New channels as a JSON array."), + eval_interval_secs: Optional[int] = typer.Option(None, "--eval-interval-secs", help="New evaluation interval in seconds (30–86400)."), + min_breaches: Optional[int] = typer.Option(None, "--min-breaches", help="New breaches-to-fire."), + eval_window: Optional[int] = typer.Option(None, "--eval-window", help="New evaluation window size."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Update an alert, referenced by **name** (or a UUID-shaped id). Two ways to change it: + + * **Tweak fields** with the override flags (e.g. `--severity critical`, `--name new-name`). + Because the server replaces the whole alert on update, the CLI re-sends the current alert + with your changes applied — so a flag-only edit needs `alerts:read` **and** `alerts:write`. + * **Replace wholesale** with `--file` (a complete AlertInput), optionally layering a few + override flags on top. + + It confirms first (default no; `--yes` skips) and renders the new state as a green "alert + updated" card. With `--json`: `{id, updated_at}`. + + Examples: + + * `fp alerts update high-errors --severity critical` — change one field + * `fp alerts update high-errors --name critical-errors --yes` — rename, no prompt + * `fp alerts update high-errors --file alert.json --yes` — replace the whole definition + """ + state: AppState = ctx.obj + overrides = _common_overrides(new_name, description, severity, trigger_kind, eval_interval_secs, min_breaches, eval_window, trigger_spec, channels) + cctx = require_auth(state) + existing = api.list_alerts(cctx) + alert = _resolve_alert_or_exit(state, existing, name) + if file is not None: + # An explicit full body is a straight replace (existing behaviour). + body = _load_file(file) + _apply_overrides(body, **overrides) + _validate_alert(body, require_core=False) + else: + # Flag-only edit: the server's PUT is a full replace, so seed the body from the resolved + # alert and overlay just the changed fields (read-merge, like `users update`). + body = _alert_to_body(alert) + _apply_overrides(body, **overrides) + _validate_alert(body, require_core=True) + final_name = body.get("name") or alert.name + if final_name != alert.name and any(a.name == final_name and a.id != alert.id for a in existing): + raise click.UsageError(f'an alert named "{final_name}" already exists') + if _write.should_prompt(state, yes): + if not output.confirm_alert_update(alert.name): + if state.json: + output.emit_json({"cancelled": True}) + else: + output.print_cancelled("nothing changed") + return + result = api.update_alert(cctx, alert.id, body) + _write.record_action("alert_updated", resource="alert", success=True) + if state.json: + output.emit_json(result) + return + final_name = body.get("name") or alert.name + updated = _refetch_alert(cctx, final_name) or _alert_from_body(body, base=alert) + output.render_alert_updated(updated, old_name=alert.name) + + +def alerts_delete( + ctx: typer.Context, + name: str = typer.Argument(..., metavar="NAME", help="Alert name (or id) to delete."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Delete an alert, referenced by **name** (or a UUID-shaped id). This cannot be undone. + + Shows an amber preview of the alert (incl. its open-incident count, which the delete orphans) + then confirms. Needs `alerts:write`. With `--json`: `{"deleted": true, "id", "name"}` (or + `{"cancelled": true}` on a declined prompt). + + Example: + + * `fp alerts delete old-test-alert` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + alert = _resolve_alert_or_exit(state, api.list_alerts(cctx), name) + if _write.should_prompt(state, yes): + output.render_alert_delete_preview(alert) # amber preview of what's about to go + if not output.confirm_alert_delete(alert.open_incidents): + if state.json: + output.emit_json({"cancelled": True}) + else: + output.print_cancelled("nothing deleted") + return + api.delete_alert(cctx, alert.id) + _write.record_action("alert_deleted", resource="alert", success=True, destructive=True) + if state.json: + output.emit_json({"deleted": True, "id": alert.id, "name": alert.name}) + else: + output.alert_deleted(alert.name) + + +def _test_channel_kinds(alert: Alert, override: Any) -> list: + """The channel kinds a test will dispatch to: the ``--channels`` override if given, else the + alert's saved channels, else (empty) the default set (slack/webhook/email).""" + chans = override if override is not None else (alert.channels or []) + if not chans: + return ["slack", "webhook", "email"] + kinds: list = [] + for c in chans: + if isinstance(c, dict): + k = c.get("kind") + if k and k not in kinds: + kinds.append(k) + return kinds + + +def alerts_test( + ctx: typer.Context, + name: str = typer.Argument(..., metavar="NAME", help="Alert name (or id) to test."), + channels: Optional[str] = typer.Option(None, "--channels", help="Channels as a JSON array (else uses the alert's saved channels)."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Fire a test notification for an alert — really sends to its email/Slack/webhook channels. + + Referenced by alert **name** (a UUID-shaped id is also accepted). Confirms first (it delivers + real notifications; `--yes` skips), then reports which channels it dispatched to. Note: the + server reports success as soon as it dispatches — actual delivery isn't confirmed. Needs + `alerts:write`. With `--json`: `{ok, synthetic_incident_id}` (or `{cancelled: true}`). + + Example: + + * `fp alerts test metric-threshold-alert` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + alert = _resolve_alert_or_exit(state, api.list_alerts(cctx), name) + override = _parse_json_opt(channels, "--channels") + if _write.should_prompt(state, yes): + if not output.confirm_alert_test(alert.name): + if state.json: + output.emit_json({"cancelled": True}) + else: + output.print_cancelled("nothing sent") + return + result = api.test_alert(cctx, alert.id, override) + _write.record_action("alert_tested", resource="alert", success=True) + if state.json: + output.emit_json(result) + return + output.alert_test_sent(alert.name, _test_channel_kinds(alert, override)) + + +_ALERTS_GROUP_HELP = """Manage alert definitions — list, inspect, create, edit, delete, and test-fire them. + +Alerts are referenced by **name** (unique per org). Each is a **trigger** (a condition shaped per +`--trigger-kind`) + an evaluation cadence + notification **channels** (email / Slack / webhook). + +**Subcommands:** `list` · `show` · `create` · `update` · `delete` · `test` + +**Examples:** + +* `fp alerts list` — all alerts, newest first +* `fp alerts show high-errors` — one alert's trigger / evaluation / channels +* `fp alerts create high-errors --trigger-kind metric_threshold --severity warning --trigger-spec '{"metric":"error_count","op":">","value":50,"window_secs":900}'` +* `fp alerts update high-errors --severity critical` — change a field +* `fp alerts test high-errors` — fire a sample notification to its channels +* `fp alerts delete high-errors` — remove it +""" + + +def register(app: typer.Typer) -> None: + alerts_app = typer.Typer( + no_args_is_help=True, + rich_markup_mode="markdown", + context_settings={"help_option_names": ["-h", "--help"]}, + help=_ALERTS_GROUP_HELP, + ) + alerts_app.command("list", epilog=GLOBALS_EPILOG)(alerts_list) + alerts_app.command("show", epilog=GLOBALS_EPILOG)(alerts_show) + alerts_app.command("create", epilog=GLOBALS_EPILOG)(alerts_create) + alerts_app.command("update", epilog=GLOBALS_EPILOG)(alerts_update) + alerts_app.command("delete", epilog=GLOBALS_EPILOG)(alerts_delete) + alerts_app.command("test", epilog=GLOBALS_EPILOG)(alerts_test) + app.add_typer(alerts_app, name="alerts") diff --git a/fp-cli/fp_cli/commands/audits_cmds.py b/fp-cli/fp_cli/commands/audits_cmds.py new file mode 100644 index 000000000..9e4f76df8 --- /dev/null +++ b/fp-cli/fp_cli/commands/audits_cmds.py @@ -0,0 +1,1054 @@ +"""Audits: audits list/show/create/edit/delete/run/runs + findings/finding + triage. + +An **audit** is a scheduled sweep over a window of agent activity (`audits create`), and what +it produces are **findings** — recurring patterns carried across runs by a fingerprint. So the +group has two handles: an audit is referenced by its **name** (unique per org, like `alerts`), +while a finding is referenced by its **id** (findings have no human name). The triage verbs +(`ack`/`mute`/`dismiss`/`resolve`/`reopen`/`assign`) act on a finding id and all post the same +status endpoint. +""" + +from __future__ import annotations + +import json +import re +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +import typer + +from .. import _click_compat as click # the Click Typer is running; see _click_compat +from .. import client as api +from .. import output +from .._context import GLOBALS_EPILOG, AppState, collect_multi, require_auth, validate_limit +from ..errors import ApiError, ForbiddenError, NotFoundError +from ..models import Audit +from . import _write + +_WINDOW_MODES = ("fixed", "since_last") +_SENSITIVITIES = ("low", "medium", "high") +_FINDING_STATUSES = ("open", "recurring", "resolved", "dismissed", "muted") +_TRIAGE_ACTIONS = ("ack", "mute", "dismiss", "resolve", "reopen", "assign") +# Triage actions that suppress or close a finding — those confirm first (`--yes` skips). +# ack / reopen / assign are calm, reversible bookkeeping, so they act immediately. +_CONFIRMING_ACTIONS = ("mute", "dismiss", "resolve") +# The server's accepted ranges (mirrored client-side so a bad value is a clean exit 2 +# instead of an HTTP 422 round-trip). +_INTERVAL_MIN, _INTERVAL_MAX = 3_600, 604_800 +_LOOKBACK_MIN, _LOOKBACK_MAX = 3_600, 7_776_000 +_UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + + +def _validate_statuses(value: Optional[str]) -> None: + """Reject an unknown ``--status`` value up front (exit 2) rather than letting the server answer + with a 422 (or, worse, silently drop it). Accepts a CSV of the five finding statuses.""" + if value is None: + return + for s in value.split(","): + s = s.strip() + if s and s not in _FINDING_STATUSES: + raise typer.BadParameter( + f"'{s}' is not a valid status. Choose from: {', '.join(_FINDING_STATUSES)} (CSV).", + param_hint="--status", + ) + + +def _validate_action(action: str) -> str: + """Guard the triage action the subcommands hand to the status endpoint. Each triage command + passes its own fixed verb, so this only trips on a coding error — but it keeps an unknown + action a clean usage error (exit 2) instead of an HTTP 422.""" + if action not in _TRIAGE_ACTIONS: + raise typer.BadParameter( + f"'{action}' is not a valid triage action. Choose from: {', '.join(_TRIAGE_ACTIONS)}.", + param_hint="--action", + ) + return action + + +def _parse_anchor(value: str) -> Optional[str]: + """Normalize a ``--schedule-anchor`` to an RFC3339 string the server's + ``DateTime<Utc>`` deserializer accepts, or ``None`` if it isn't a timestamp. + + Accepts a trailing ``Z`` (which ``datetime.fromisoformat`` rejects before 3.11) + and a naive timestamp, which is read as UTC — audits are UTC end to end and + there is no timezone anywhere in this system.""" + raw = value.strip() + if not raw: + return None + try: + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _validate_audit(body: Dict[str, Any], *, require_core: bool) -> None: + """Client-side validation of an audit body (exit 2 on a bad value), mirroring the server's + accepted ranges/enums so a typo never costs a round-trip or surfaces as a raw 422. + + Also normalizes ``schedule_anchor`` in place to RFC3339 — see the comment there.""" + if require_core and not str(body.get("name") or "").strip(): + raise typer.BadParameter("audit 'name' is required.") + interval = body.get("schedule_interval_secs") + if isinstance(interval, int) and not (_INTERVAL_MIN <= interval <= _INTERVAL_MAX): + raise typer.BadParameter( + f"schedule_interval_secs must be between {_INTERVAL_MIN} (1h) and {_INTERVAL_MAX} (7d).", + param_hint="--schedule-interval-secs", + ) + lookback = body.get("lookback_window_secs") + if isinstance(lookback, int) and not (_LOOKBACK_MIN <= lookback <= _LOOKBACK_MAX): + raise typer.BadParameter( + f"lookback_window_secs must be between {_LOOKBACK_MIN} (1h) and {_LOOKBACK_MAX} (90d).", + param_hint="--lookback-window-secs", + ) + mode = body.get("window_mode") + if mode and mode not in _WINDOW_MODES: + raise typer.BadParameter( + f"window_mode must be one of: {', '.join(_WINDOW_MODES)}.", param_hint="--window-mode" + ) + anchor = body.get("schedule_anchor") + if anchor is not None: + normalized = _parse_anchor(str(anchor)) + if not normalized: + raise typer.BadParameter( + "schedule_anchor must be an ISO 8601 timestamp, e.g. 2026-07-22T09:00:00Z.", + param_hint="--schedule-anchor", + ) + # Normalized in place, so every write path (flags, --file, the edit + # read-merge) sends the RFC3339 form the server's DateTime<Utc> + # deserializer accepts. Rewriting it here rather than in the override + # builder keeps an unparseable value reaching the check above instead of + # being silently dropped as "not supplied". + body["schedule_anchor"] = normalized + sensitivity = body.get("sensitivity") + if sensitivity and sensitivity not in _SENSITIVITIES: + raise typer.BadParameter( + f"sensitivity must be one of: {', '.join(_SENSITIVITIES)}.", param_hint="--sensitivity" + ) + top_k = body.get("top_k") + if isinstance(top_k, int) and top_k < 1: + raise typer.BadParameter("top_k must be at least 1.", param_hint="--top-k") + + +def _fail(state: AppState, exc: Exception, *, finding_id: str = "") -> None: + """Re-raise as a typed error for the central chokepoint to render (JSON envelope under + ``--json`` on stdout, red box otherwise). A 404 — or a **malformed (non-UUID) finding id**, + which the server answers with a 400 from its path extractor rather than a 404 — becomes the + friendlier ``no finding <id>`` (exit 6); a permission denial and every other error keep the + server's message and exit code.""" + if isinstance(exc, ForbiddenError): + raise exc # a 403 reached the handler with a usable id — surface it as-is + status = getattr(exc, "status", None) or 0 + malformed = bool(finding_id) and not _UUID_RE.match(finding_id) and status >= 400 + if finding_id and (malformed or isinstance(exc, NotFoundError)): + raise NotFoundError( + f"no finding {finding_id}", + hint="run `fp audits findings` to list findings", + ) + raise exc + + +def _parse_json_opt(value: Optional[str], hint: str) -> Any: + if value is None: + return None + try: + return json.loads(value) + except json.JSONDecodeError as exc: + raise typer.BadParameter(f"{hint} is not valid JSON: {exc}", param_hint=hint) + + +# Mirrors MAX_CONTEXT_CHARS / MAX_REFERENCE_URLS in server/src/audits/context.rs. +# Checked here so an over-long brief is a usage error at exit 2, not a 422 after +# the request went out. +_MAX_CONTEXT_CHARS = 8192 +_MAX_REFERENCE_URLS = 5 + + +def _context_text(text: Optional[str], text_file: Optional[str]) -> Optional[str]: + """The brief from ``--text`` or ``--text-file``. ``None`` means neither was given. + + Shared by ``audits create`` and ``audits context-set`` so the two flags mean the same + thing on both, and the cap is stated once.""" + if text is not None and text_file: + raise typer.BadParameter("pass --text or --text-file, not both.", param_hint="--text-file") + body_text = text + if text_file: + try: + with open(text_file, "r", encoding="utf-8") as fh: + body_text = fh.read() + except OSError as exc: + raise typer.BadParameter(f"could not read {text_file}: {exc}", param_hint="--text-file") + if body_text is not None and len(body_text) > _MAX_CONTEXT_CHARS: + raise typer.BadParameter( + f"the brief is limited to {_MAX_CONTEXT_CHARS} characters.", param_hint="--text" + ) + return body_text + + +def _context_urls(url: Optional[List[str]]) -> List[str]: + """The ``--url`` list, capped where the server caps it.""" + urls = list(url or []) + if len(urls) > _MAX_REFERENCE_URLS: + raise typer.BadParameter( + f"an audit may reference at most {_MAX_REFERENCE_URLS} URLs.", param_hint="--url" + ) + return urls + + +# Keys `audits show --json` emits that the definition endpoint will not accept. +# +# Server-derived state the server simply ignores, plus the two context keys it +# actively 422s (they are written through `PUT /audits/{id}/context`). Dropping +# them here is what makes the documented `audits show --json > f && audits edit +# --file f` round-trip work: without it the file carries `additional_context`, +# the server rejects the whole request, and the edit fails for a field the +# operator never touched. +_READ_ONLY_AUDIT_KEYS = frozenset({ + "id", "created_by", "created_at", "updated_at", + "open_findings", "run_count", + "last_run_status", "last_run_finished_at", "last_attempted_at", + "next_attempt_at", "last_error", + # Written through the context sub-resource, not here. + "additional_context", "reference_urls", "reference_url_count", +}) + + +def _load_file(file: Optional[str]) -> Dict[str, Any]: + if file is None: + return {} + raw = _write.read_text_arg(file) + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise typer.BadParameter(f"--file is not valid JSON: {exc}", param_hint="--file") + if not isinstance(parsed, dict): + raise typer.BadParameter("--file must contain a JSON object.", param_hint="--file") + return {k: v for k, v in parsed.items() if k not in _READ_ONLY_AUDIT_KEYS} + + +def _apply_overrides(body: Dict[str, Any], **overrides: Any) -> Dict[str, Any]: + for key, value in overrides.items(): + if value is not None: + body[key] = value + return body + + +def _audit_to_body(audit: Audit) -> Dict[str, Any]: + """The writable fields of an existing audit, used as the merge base for a flag-only edit. + + The server's ``PUT /api/audits/{id}`` replaces the definition (and ``name`` is mandatory on + it), so a single-field change must re-send the whole audit. Server-derived state + (``open_findings``, the last-run columns) is deliberately excluded — it is read-only.""" + return { + "name": audit.name, + "description": audit.description, + "enabled": audit.enabled, + "schedule_interval_secs": audit.schedule_interval_secs, + "schedule_anchor": audit.schedule_anchor, + "window_mode": audit.window_mode, + "lookback_window_secs": audit.lookback_window_secs, + "scope": audit.scope, + "ignore_error_types": audit.ignore_error_types, + "llm_enabled": audit.llm_enabled, + "top_k": audit.top_k, + "sensitivity": audit.sensitivity, + "channels": audit.channels, + } + + +def _audit_from_body(body: Dict[str, Any], base: Optional[Audit] = None) -> Audit: + """Build an ``Audit`` view from a create/edit request body — the fallback when the canonical + re-fetch misses. ``base`` (the pre-edit audit) supplies id/created/derived context; a create + has none, so those default to empty.""" + merged: Dict[str, Any] = dict(_audit_to_body(base)) if base else {} + merged.update({k: v for k, v in body.items() if v is not None}) + merged.setdefault("id", base.id if base else "") + return Audit.from_dict({ + **merged, + "id": base.id if base else "", + "created_by": base.created_by if base else "", + "created_at": base.created_at if base else "", + "open_findings": base.open_findings if base else 0, + "last_run_status": base.last_run_status if base else None, + "last_run_finished_at": base.last_run_finished_at if base else None, + }) + + +def _refetch_audit(cctx, name: Optional[str]) -> Optional[Audit]: + """Re-read the canonical stored audit by name after a create/edit (the server busts the list + cache on write, so this reflects the saved state incl. server-applied defaults). Returns + ``None`` on any miss so the caller falls back to rendering from the request body.""" + if not name: + return None + try: + for a in api.list_audits(cctx): + if a.name == name: + return a + except Exception: + return None + return None + + +def _resolve_audit_or_exit(audits, handle: str) -> Audit: + """Resolve an audit by **name** (primary), falling back to an exact id match, raising a typed + error the central chokepoint renders: none → exit 6, several → exit 2.""" + return _write.resolve_one(audits, handle, kind="audit", list_cmd="audits list") + + +def audits_list( + ctx: typer.Context, + enabled_only: bool = typer.Option(False, "--enabled-only", help="Only audits that are switched on."), + show_id: bool = typer.Option(False, "--show-id", help="Prepend a short audit-id column (the full id is always in --json)."), +) -> None: + """List audit definitions in a boxed table, newest first. + + Shows `created · name · by · every · findings · status · last run` — `every` is the humanized + schedule interval, `findings` the open-finding count (pink when there's something to triage), + and `last run` the age of the last run tinted by its outcome (`never` if it hasn't run yet). + Disabled audits are dimmed; the on/off split is in the footer. `name` is the handle the other + subcommands take; the raw id is hidden unless `--show-id`. Needs `audits:read`. With `--json`: + `{"audits": [{id, name, enabled, schedule_interval_secs, window_mode, scope, open_findings, + last_run_status, created_by, created_at, ...}]}`. + + Example: + + * `fp audits list --enabled-only` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + audits = api.list_audits(cctx) + if enabled_only: + audits = [a for a in audits if a.enabled] + if state.json: + output.emit_json({"audits": audits}) + return + output.render_audits(audits, show_id=show_id) + output.audits_footer(audits) + + +def audits_show( + ctx: typer.Context, + name: str = typer.Argument(..., metavar="NAME", help="Audit name (or id)."), +) -> None: + """Show one audit as a stack of cards — identity, schedule, scope, analysis, channels. + + Referenced by audit **name** (a UUID-shaped id is also accepted). The identity card carries the + on/off state, the cadence and the open-finding count; `scope` shows what activity the audit + covers, `analysis` the LLM/sensitivity settings, `channels` default-vs-custom delivery. + Not-found → red `✗ no audit named "…"`, exit 6. Needs `audits:read`. With `--json`: the full + raw `Audit` (untouched `scope` + `channels`). + + Example: + + * `fp audits show weekly-failure-audit` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + audit = _resolve_audit_or_exit(api.list_audits(cctx), name) + if state.json: + output.emit_json(audit) + return + output.render_audit_show(audit) + + +def _definition_overrides( + name, description, enabled, schedule_interval_secs, schedule_anchor, window_mode, + lookback_window_secs, scope, ignore_error_type, llm_enabled, top_k, sensitivity, channels, +) -> Dict[str, Any]: + """The discrete definition flags → an overrides dict (``None`` = "not supplied", so it never + clobbers a value carried over from `--file` or the existing audit).""" + return dict( + name=name, + description=description, + enabled=enabled, + schedule_interval_secs=schedule_interval_secs, + # Raw here on purpose — `_validate_audit` parses/normalizes it. Parsing at + # this point would turn a malformed anchor into None, i.e. "not supplied", + # and silently ignore the flag instead of erroring. + schedule_anchor=schedule_anchor, + window_mode=window_mode, + lookback_window_secs=lookback_window_secs, + scope=_parse_json_opt(scope, "--scope"), + ignore_error_types=collect_multi(ignore_error_type), + llm_enabled=llm_enabled, + top_k=top_k, + sensitivity=sensitivity, + channels=_parse_json_opt(channels, "--channels"), + ) + + +def audits_create( + ctx: typer.Context, + name: str = typer.Argument(..., metavar="NAME", help="Audit name (unique per org)."), + file: Optional[str] = typer.Option(None, "--file", help="Full audit JSON to base it on, or `-` for stdin."), + description: Optional[str] = typer.Option(None, "--description", help="What this audit is for."), + enabled: Optional[bool] = typer.Option(None, "--enabled/--disabled", help="Start it on or off (default: on)."), + schedule_interval_secs: Optional[int] = typer.Option(None, "--schedule-interval-secs", help=f"How often it runs, in seconds ({_INTERVAL_MIN}–{_INTERVAL_MAX})."), + schedule_anchor: Optional[str] = typer.Option(None, "--schedule-anchor", help="Fixed UTC slot the schedule is phased to, ISO 8601 (default: next 09:00 UTC)."), + window_mode: Optional[str] = typer.Option(None, "--window-mode", help=f"Which window each run sweeps: {', '.join(_WINDOW_MODES)}."), + lookback_window_secs: Optional[int] = typer.Option(None, "--lookback-window-secs", help=f"How far back a run looks, in seconds ({_LOOKBACK_MIN}–{_LOOKBACK_MAX})."), + scope: Optional[str] = typer.Option(None, "--scope", help="Scope filter as JSON, e.g. `{\"environments\":[\"prod\"]}` (omit to cover everything)."), + ignore_error_type: Optional[List[str]] = typer.Option(None, "--ignore-error-type", help="Error type to exclude (repeatable, or CSV)."), + llm_enabled: Optional[bool] = typer.Option(None, "--llm/--no-llm", help="Use the LLM analysis pass (default: on)."), + top_k: Optional[int] = typer.Option(None, "--top-k", help="Max findings a run keeps."), + sensitivity: Optional[str] = typer.Option(None, "--sensitivity", help=f"How eagerly it flags: {', '.join(_SENSITIVITIES)}."), + channels: Optional[str] = typer.Option(None, "--channels", help="Channels as a JSON array, e.g. `[{\"kind\":\"slack\"}]` (omit for the org defaults)."), + text: Optional[str] = typer.Option( + None, "--text", help="Operator brief for the analysis prompt (max 8192 chars)." + ), + text_file: Optional[str] = typer.Option( + None, "--text-file", help="Read the brief from a file instead of --text." + ), + url: Optional[List[str]] = typer.Option( + None, "--url", help="Reference URL; repeat up to 5 times. Public https:// only." + ), +) -> None: + """Create an audit and show it rendered as parsed cards. + + Give it a **name** (positional), then define it with the flags or base it on a full JSON + payload via `--file` (flags layer on top). Everything except the name has a server default — + a bare `fp audits create nightly` gives you a daily, LLM-backed audit over all activity. + On success it renders exactly the way `audits show` does, in a green "audit created" card. + Creating isn't destructive, so there's no confirm; a name collision is rejected up front + (exit 2). New audits start **enabled** unless you pass `--disabled`. Needs `audits:write`. + With `--json`: `{id, created_at, sources}`. + + `--text`/`--text-file`/`--url` attach reference context in the SAME request, which is + also the only way to be sure the first run has it: a new enabled audit is due + immediately, so context set afterwards can miss it. A URL the guard refuses fails the + whole create — no half-made audit is left behind. Change it later with + `audits context-set`. + + Examples: + + * `fp audits create nightly-prod --scope '{"environments":["prod"]}' --schedule-interval-secs 86400` + * `fp audits create nightly-prod --text "checkout agent for a retail store" --url https://docs.example.com/runbook` + * `fp audits create weekly --file audit.json` — base it on a saved payload + * `fp audits create weekly --file audit.json --sensitivity high` — file + an override + """ + state: AppState = ctx.obj + body = _load_file(file) + _apply_overrides(body, **_definition_overrides( + name, description, enabled, schedule_interval_secs, schedule_anchor, window_mode, + lookback_window_secs, scope, ignore_error_type, llm_enabled, top_k, sensitivity, channels, + )) + # Context travels WITH the definition — the server writes both in one + # transaction, so the run it queues cannot start before the material lands. + # Flags layer over `--file` here too; neither flag leaves whatever the file + # carried alone. + brief, urls = _context_text(text, text_file), _context_urls(url) + if brief is not None or urls: + body["context"] = {"text": brief or "", "urls": urls} + _validate_audit(body, require_core=True) + cctx = require_auth(state) + if any(a.name == body.get("name") for a in api.list_audits(cctx)): # names are the handle → unique + raise click.UsageError(f'an audit named "{body.get("name")}" already exists') + result = api.create_audit(cctx, body) + _write.record_action("audit_created", resource="audit", success=True) + if body.get("context"): + # Same event and the same allowlisted properties as `context-set`, so + # "was context filled in at creation?" is answerable across surfaces. + # `via` and `url_count` are both on _SAFE_PROP_KEYS; shape only, never a URL. + _write.record_action( + "audit_context_saved", resource="audit", success=True, via="cli", + url_count=len(body["context"]["urls"]), + ) + if state.json: + output.emit_json(result) + return + # Re-read the canonical stored audit (server defaults applied) for the rendered cards. + audit = _refetch_audit(cctx, body.get("name")) or _audit_from_body(body) + output.render_audit_created(audit) + + +def audits_edit( + ctx: typer.Context, + name: str = typer.Argument(..., metavar="NAME", help="Audit name (or id) to edit."), + new_name: Optional[str] = typer.Option(None, "--name", help="Rename the audit."), + file: Optional[str] = typer.Option(None, "--file", help="Full audit JSON to replace it with, or `-` for stdin."), + description: Optional[str] = typer.Option(None, "--description", help="New description."), + enabled: Optional[bool] = typer.Option(None, "--enabled/--disabled", help="Switch the audit on or off."), + schedule_interval_secs: Optional[int] = typer.Option(None, "--schedule-interval-secs", help=f"New run interval, in seconds ({_INTERVAL_MIN}–{_INTERVAL_MAX})."), + schedule_anchor: Optional[str] = typer.Option(None, "--schedule-anchor", help="New fixed UTC slot the schedule is phased to, ISO 8601."), + window_mode: Optional[str] = typer.Option(None, "--window-mode", help=f"New window mode: {', '.join(_WINDOW_MODES)}."), + lookback_window_secs: Optional[int] = typer.Option(None, "--lookback-window-secs", help=f"New lookback, in seconds ({_LOOKBACK_MIN}–{_LOOKBACK_MAX})."), + scope: Optional[str] = typer.Option(None, "--scope", help="New scope filter as JSON."), + ignore_error_type: Optional[List[str]] = typer.Option(None, "--ignore-error-type", help="Replace the ignored error types (repeatable, or CSV)."), + llm_enabled: Optional[bool] = typer.Option(None, "--llm/--no-llm", help="Turn the LLM analysis pass on or off."), + top_k: Optional[int] = typer.Option(None, "--top-k", help="New max findings per run."), + sensitivity: Optional[str] = typer.Option(None, "--sensitivity", help=f"New sensitivity: {', '.join(_SENSITIVITIES)}."), + channels: Optional[str] = typer.Option(None, "--channels", help="New channels as a JSON array."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Edit an audit, referenced by **name** (or a UUID-shaped id). Two ways to change it: + + * **Tweak fields** with the override flags (e.g. `--sensitivity high`, `--disabled`). Because + the server replaces the whole definition, the CLI re-sends the current audit with your + changes applied — so a flag-only edit needs `audits:read` **and** `audits:write`. + * **Replace wholesale** with `--file` (a complete audit payload), optionally layering a few + override flags on top. + + It confirms first (default no; `--yes` skips) and renders the new state as a green "audit + updated" card. A rename onto an existing name is rejected (exit 2). Needs `audits:write`. + With `--json`: `{id, updated}`. + + Examples: + + * `fp audits edit nightly-prod --sensitivity high` — change one field + * `fp audits edit nightly-prod --disabled --yes` — pause it, no prompt + * `fp audits edit nightly-prod --name nightly --yes` — rename + """ + state: AppState = ctx.obj + overrides = _definition_overrides( + new_name, description, enabled, schedule_interval_secs, schedule_anchor, window_mode, + lookback_window_secs, scope, ignore_error_type, llm_enabled, top_k, sensitivity, channels, + ) + cctx = require_auth(state) + existing = api.list_audits(cctx) + audit = _resolve_audit_or_exit(existing, name) + if file is not None: + # An explicit full body is a straight replace. + body = _load_file(file) + _apply_overrides(body, **overrides) + body.setdefault("name", audit.name) + else: + # Flag-only edit: the server replaces the definition, so seed the body from the resolved + # audit and overlay just the changed fields (read-merge, like `alerts update`). + body = _audit_to_body(audit) + _apply_overrides(body, **overrides) + _validate_audit(body, require_core=True) + final_name = body.get("name") or audit.name + if final_name != audit.name and any(a.name == final_name and a.id != audit.id for a in existing): + raise click.UsageError(f'an audit named "{final_name}" already exists') + if _write.should_prompt(state, yes): + if not output.confirm_audit_edit(audit.name): + if state.json: + output.emit_json({"cancelled": True}) + else: + output.cancelled_plain("nothing changed") + return + result = api.update_audit(cctx, audit.id, body) + _write.record_action("audit_updated", resource="audit", success=True) + if state.json: + output.emit_json(result) + return + updated = _refetch_audit(cctx, final_name) or _audit_from_body(body, base=audit) + output.render_audit_updated(updated, old_name=audit.name) + + +def audits_delete( + ctx: typer.Context, + name: str = typer.Argument(..., metavar="NAME", help="Audit name (or id) to delete."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Delete an audit, referenced by **name** (or a UUID-shaped id). This cannot be undone. + + Shows an amber preview of the audit (incl. its open-finding count — the findings and the run + history go with it) then confirms. Needs `audits:write`. With `--json`: `{"deleted": true, "id", + "name"}` (or `{"cancelled": true}` on a declined prompt). + + Example: + + * `fp audits delete old-experiment --yes` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + audit = _resolve_audit_or_exit(api.list_audits(cctx), name) + if _write.should_prompt(state, yes): + output.render_audit_delete_preview(audit) # amber preview of what's about to go + if not output.confirm_audit_delete(audit.open_findings): + if state.json: + output.emit_json({"cancelled": True}) + else: + output.cancelled_plain("nothing deleted") + return + api.delete_audit(cctx, audit.id) + _write.record_action("audit_deleted", resource="audit", success=True, destructive=True) + if state.json: + output.emit_json({"deleted": True, "id": audit.id, "name": audit.name}) + else: + output.audit_deleted(audit.name) + + +def audits_run( + ctx: typer.Context, + name: str = typer.Argument(..., metavar="NAME", help="Audit name (or id) to run now."), +) -> None: + """Queue an audit to run now, ahead of its schedule. + + This makes the audit **due**; the dispatcher picks it up on its next tick, so success here + means "queued", not "finished" — follow it with `fp audits runs <name>`. An audit that + is disabled, or that already has a run in progress, is refused with the server's explanation + (exit 1) rather than silently double-queued. Needs `audits:write`. With `--json`: + `{"queued": true}`. + + Example: + + * `fp audits run nightly-prod` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + audit = _resolve_audit_or_exit(api.list_audits(cctx), name) + try: + result = api.run_audit(cctx, audit.id) + except ApiError as exc: + if getattr(exc, "status", None) == 409: + # A run is already in progress (or the audit is disabled) — the server's message says + # which; add the "what now" pointer the bare message lacks. + raise ApiError( + exc.message, + status=exc.status, + request_id=exc.request_id, + hint=f"check it with `fp audits runs {audit.name}`", + ) + raise + _write.record_action("audit_run_queued", resource="audit", success=True) + if state.json: + output.emit_json(result) + else: + output.audit_run_queued(audit.name) + + +def audits_context_show( + ctx: typer.Context, + name: str = typer.Argument(..., metavar="NAME", help="Audit name (or id)."), +) -> None: + """Show the reference context an audit sends to the analysis prompt. + + Prints the operator brief plus every reference URL with its fetch state: how many + characters were stored, whether the snapshot was truncated, how many secret-shaped + values were masked, and whether the page contains phrases that read as instructions + to an AI (which is worth reading before the next run). Needs `audits:read`. + + Example: + + * `fp audits context-show nightly-prod` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + audit = _resolve_audit_or_exit(api.list_audits(cctx), name) + result = api.get_audit_context(cctx, audit.id) + if state.json: + output.emit_json(result) + else: + output.audit_context(audit.name, result) + + +def audits_context_set( + ctx: typer.Context, + name: str = typer.Argument(..., metavar="NAME", help="Audit name (or id)."), + text: Optional[str] = typer.Option( + None, "--text", help="Operator brief (max 8192 chars). Pass an empty string to clear it." + ), + text_file: Optional[str] = typer.Option( + None, "--text-file", help="Read the brief from a file instead of --text." + ), + url: Optional[List[str]] = typer.Option( + None, "--url", help="Reference URL; repeat up to 5 times. Public https:// only. " + "Replaces the existing list; omit to leave it unchanged." + ), + clear_urls: bool = typer.Option( + False, "--clear-urls", help="Remove every reference URL and its stored snapshot." + ), +) -> None: + """Update an audit's reference context. + + Each half is independent: pass `--text`/`--text-file` to change the brief, `--url` + to replace the URL list, or both. **Whatever you omit is left alone.** Removing + something is always explicit — `--text ""` clears the brief, `--clear-urls` drops + every URL and its stored snapshot. + + Omission used to mean "delete" for URLs but "keep" for the brief, so a routine + `--text` edit silently threw away every reference page. + + URLs are validated immediately (public `https://` only; private, loopback and + cloud-metadata addresses are refused) and fetched in the background, so a slow site + never blocks the save and never blocks a run. Needs `audits:write`. + + Examples: + + * `fp audits context-set nightly-prod --text "checkout agent for a retail store"` + * `fp audits context-set nightly-prod --url https://docs.example.com/runbook` + * `fp audits context-set nightly-prod --text ""` — clear the brief + * `fp audits context-set nightly-prod --clear-urls` — drop the pages + """ + state: AppState = ctx.obj + cctx = require_auth(state) + body_text = _context_text(text, text_file) + urls = _context_urls(url) + if clear_urls and urls: + raise typer.BadParameter("pass --url or --clear-urls, not both.", param_hint="--clear-urls") + if body_text is None and not urls and not clear_urls: + raise typer.BadParameter( + "nothing to change — pass --text, --text-file, --url or --clear-urls.", + param_hint="--text", + ) + + audit = _resolve_audit_or_exit(api.list_audits(cctx), name) + # The endpoint is a FULL replacement, so anything the caller did not name has + # to be read back and re-sent. Both halves, symmetrically: this used to merge + # the brief and not the URLs, which made `--text` alone a silent delete of + # every reference page — the read-merge-allowlist class recorded in + # models.py:116, in miniature. + if body_text is None or not (urls or clear_urls): + current = api.get_audit_context(cctx, audit.id) + if body_text is None: + body_text = current.get("text", "") + if not urls and not clear_urls: + urls = [str(s.get("url") or "") for s in (current.get("sources") or [])] + urls = [u for u in urls if u] + result = api.put_audit_context(cctx, audit.id, {"text": body_text, "urls": urls}) + # Shape only — how many URLs, never which. The name is `url_count` because that is + # what the dashboard sends for this same quantity (the API field is + # `reference_url_count`); the CLI used to send `count`, so one event carried two + # property names and neither answered it alone. It is on _SAFE_PROP_KEYS — + # anything not on that allowlist is dropped silently, so a renamed property needs + # its entry there in the same change or it never existed. + # `via` names the surface this was written from, so the one series splits + # into create / settings / cli rather than answering "somebody saved + # context" and nothing more. The dashboard emits the other two values. + # Already on _SAFE_PROP_KEYS, so it survives the allowlist. + _write.record_action( + "audit_context_saved", resource="audit", success=True, via="cli", url_count=len(urls) + ) + if state.json: + output.emit_json(result) + else: + output.audit_context_saved(audit.name, result) + + +def audits_context_refresh( + ctx: typer.Context, + name: str = typer.Argument(..., metavar="NAME", help="Audit name (or id)."), +) -> None: + """Re-fetch every reference URL on an audit. + + Snapshots refresh on their own weekly, so this is for when you know a page changed + and want it picked up now. URLs the guard refused are not retried — nothing about + them can change until the URL itself does. Needs `audits:write`. + + Example: + + * `fp audits context-refresh nightly-prod` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + audit = _resolve_audit_or_exit(api.list_audits(cctx), name) + result = api.refresh_audit_context(cctx, audit.id) + # `url_count` is how many URLs the server queued, under the same property name the + # dashboard sends — see the note in `audits_context_set`. + _write.record_action( + "audit_context_refreshed", resource="audit", success=True, + url_count=int(result.get("queued") or 0), + ) + if state.json: + output.emit_json(result) + else: + output.audit_context_refreshed(audit.name, result) + + +def audits_runs( + ctx: typer.Context, + name: str = typer.Argument(..., metavar="NAME", help="Audit name (or id)."), + limit: int = typer.Option(50, "--limit", "-n", help="Max runs to show (the server returns the 50 most recent)."), + show_id: bool = typer.Option(False, "--show-id", help="Prepend a short run-id column (the full id is always in --json)."), +) -> None: + """List an audit's run history in a boxed table, newest first. + + Shows `started · status · trigger · findings · new · took` — `findings` is the run's total, + `new` the count first seen in that run, `took` the wall time (`-` while a run is still going). + A failed run's `error` and each run's `stats`/`report` live in `--json`. Needs `audits:read`. + With `--json`: `{"runs": [{id, status, trigger_kind, window_from, window_to, started_at, + finished_at, stats, findings_count, new_findings_count, report, error}]}`. + + Example: + + * `fp audits runs nightly-prod --limit 10` + """ + state: AppState = ctx.obj + validate_limit(limit) + cctx = require_auth(state) + audit = _resolve_audit_or_exit(api.list_audits(cctx), name) + runs = api.list_audit_runs(cctx, audit.id)[:limit] + if state.json: + output.emit_json({"runs": runs}) + return + output.render_audit_runs(runs, name=audit.name, show_id=show_id) + output.audit_runs_footer(runs) + + +def audits_findings( + ctx: typer.Context, + audit: Optional[str] = typer.Option(None, "--audit", help="Only findings from this audit (name, or id)."), + run_id: Optional[str] = typer.Option(None, "--run-id", help="Only findings produced or updated by this run."), + status: Optional[str] = typer.Option(None, "--status", help=f"Filter by status: {', '.join(_FINDING_STATUSES)} (CSV; default: open + recurring)."), + limit: int = typer.Option(100, "--limit", "-n", help="Max findings to return (server caps at 500)."), + offset: int = typer.Option(0, "--offset", help="Skip this many findings (paging)."), + show_id: bool = typer.Option(False, "--show-id", help="Show full finding ids instead of the short form (always full in --json)."), +) -> None: + """List findings across audits in a boxed table, highest priority first. + + Shows `id · title · severity · status · kind · seen · last` — the id is the handle the triage + commands take (short by default, full with `--show-id`); `kind` separates a `failure` from a + `policy` violation or an `improvement`; `seen` is the occurrence count and `last` the age of the + most recent sighting. With no `--status` the server returns the live set (open + recurring). + Suppressed findings are dimmed; a status/severity breakdown is in the footer. `--audit` takes an + audit **name**. Needs `audits:read`. With `--json`: `{"findings": [{id, audit_id, audit_name, + title, severity, status, kind, priority, occurrences, last_seen_at, recommendation, ...}]}`. + + Examples: + + * `fp audits findings --status open --limit 20` + * `fp audits findings --audit nightly-prod` + """ + state: AppState = ctx.obj + _validate_statuses(status) + validate_limit(limit) + if offset < 0: + raise typer.BadParameter("must be zero or a positive integer.", param_hint="--offset") + cctx = require_auth(state) + audit_id = None + if audit: + audit_id = _resolve_audit_or_exit(api.list_audits(cctx), audit).id + findings = api.list_audit_findings( + cctx, audit_id=audit_id, run_id=run_id, status=status, limit=limit, offset=offset + ) + if state.json: + output.emit_json({"findings": findings}) + return + output.render_findings(findings, show_id=show_id) + output.findings_footer(findings) + + +def audits_finding( + ctx: typer.Context, + finding_id: str = typer.Argument(..., metavar="FINDING_ID", help="Finding id."), +) -> None: + """Show one finding in full — a stack of cards: identity, analysis, recommendation, evidence. + + The identity card carries severity · status · kind · magnitude, how often it's been seen, the + owning audit and any assignee; `analysis` holds the description and the root-cause hypothesis, + `recommendation` the suggested fix with its expected impact and effort. Empty sections are + omitted. Not-found (or a malformed id) → `✗ no finding <id>`, exit 6. Needs `audits:read`. + With `--json`: the full `AuditFinding` (untouched `evidence`/`evidence_queries`/`scope`). + + Example: + + * `fp audits finding <id>` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + try: + finding = api.get_audit_finding(cctx, finding_id) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc, finding_id=finding_id) + if state.json: + output.emit_json(finding) + return + output.render_finding_show(finding) + + +def _triage( + ctx: typer.Context, + action: str, + finding_id: str, + *, + reason: Optional[str] = None, + assigned_to: Optional[str] = None, + assume_yes: bool = True, +) -> None: + """The shared body behind every triage verb: validate → (confirm) → POST the status action → + record → render. The suppressing/closing actions (mute/dismiss/resolve) confirm first; ack, + reopen and assign act immediately (they're calm and reversible).""" + state: AppState = ctx.obj + _validate_action(action) + cctx = require_auth(state) + if action in _CONFIRMING_ACTIONS and _write.should_prompt(state, assume_yes): + title = None + try: + title = api.get_audit_finding(cctx, finding_id).title + except NotFoundError as exc: + _fail(state, exc, finding_id=finding_id) + except (ApiError, ForbiddenError): + title = None + if not output.confirm_finding_action(action, finding_id, title=title): + if state.json: + output.emit_json({"cancelled": True}) + else: + output.cancelled_plain("nothing changed") + return + try: + result = api.set_finding_status( + cctx, finding_id, action=action, reason=reason, assigned_to=assigned_to + ) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc, finding_id=finding_id) + _write.record_action(f"finding_{action}", resource="finding", success=True) + if state.json: + output.emit_json(result) + else: + output.finding_triaged(action, finding_id, assigned_to=assigned_to) + + +def findings_ack( + ctx: typer.Context, + finding_id: str = typer.Argument(..., metavar="FINDING_ID", help="Finding id."), + reason: Optional[str] = typer.Option(None, "--reason", help="Why you're acknowledging it (kept as durable feedback)."), +) -> None: + """Acknowledge a finding — you've seen it and it stays visible, just deprioritized. + + The gentlest triage verb: the status doesn't change, but the acknowledgement is recorded as + durable feedback so later runs rank the pattern lower. No confirm (it isn't destructive). + Needs `audits:write`. With `--json`: `{id, action, ok}`. + + Example: + + * `fp audits ack <finding-id> --reason "known, fix is queued"` + """ + _triage(ctx, "ack", finding_id, reason=reason) + + +def findings_mute( + ctx: typer.Context, + finding_id: str = typer.Argument(..., metavar="FINDING_ID", help="Finding id."), + reason: Optional[str] = typer.Option(None, "--reason", help="Why you're muting it (kept as durable feedback)."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Mute a finding — stop future runs surfacing this pattern at all. + + The strongest "don't show me this again" action: the finding goes to `muted` and the + suppression is durable, so a re-detection of the same fingerprint stays hidden until you + `reopen` it. Confirms first (`--yes` skips). Needs `audits:write`. With `--json`: + `{id, action, ok}` (or `{"cancelled": true}` on a declined prompt). + + Example: + + * `fp audits mute <finding-id> --reason "expected in staging" --yes` + """ + _triage(ctx, "mute", finding_id, reason=reason, assume_yes=yes) + + +def findings_dismiss( + ctx: typer.Context, + finding_id: str = typer.Argument(..., metavar="FINDING_ID", help="Finding id."), + reason: Optional[str] = typer.Option(None, "--reason", help="Why you're dismissing it (kept as durable feedback)."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Dismiss a finding — it's not worth acting on. + + Sets the status to `dismissed` and records durable feedback so the pattern is suppressed in + later runs (like `mute`, but the label says "judged not a problem" rather than "hide this"). + Confirms first (`--yes` skips). Needs `audits:write`. With `--json`: `{id, action, ok}` (or + `{"cancelled": true}` on a declined prompt). + + Example: + + * `fp audits dismiss <finding-id> --reason "false positive" --yes` + """ + _triage(ctx, "dismiss", finding_id, reason=reason, assume_yes=yes) + + +def findings_resolve( + ctx: typer.Context, + finding_id: str = typer.Argument(..., metavar="FINDING_ID", help="Finding id."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Resolve a finding — you fixed it. + + Sets the status to `resolved` and leaves **no** suppression behind, deliberately: if the + pattern genuinely comes back, the next run should raise it as new. Confirms first (`--yes` + skips). Needs `audits:write`. With `--json`: `{id, action, ok}` (or `{"cancelled": true}` on a + declined prompt). + + Example: + + * `fp audits resolve <finding-id> --yes` + """ + _triage(ctx, "resolve", finding_id, assume_yes=yes) + + +def findings_reopen( + ctx: typer.Context, + finding_id: str = typer.Argument(..., metavar="FINDING_ID", help="Finding id."), +) -> None: + """Re-open a finding — put it back in the live queue. + + Sets the status back to `open` **and clears any mute/dismiss suppression**, so the pattern can + rank and resurface normally. The undo for `mute`/`dismiss`/`resolve`. No confirm (it only makes + things more visible). Needs `audits:write`. With `--json`: `{id, action, ok}`. + + Example: + + * `fp audits reopen <finding-id>` + """ + _triage(ctx, "reopen", finding_id) + + +def findings_assign( + ctx: typer.Context, + finding_id: str = typer.Argument(..., metavar="FINDING_ID", help="Finding id."), + assignee: str = typer.Option(..., "--to", help="Email of the person who owns this finding."), +) -> None: + """Assign a finding to someone (sets its owner; the status is untouched). + + `--to` is required — the server rejects an assign without one. Re-running it reassigns. + No confirm (it isn't destructive). Needs `audits:write`. With `--json`: `{id, action, ok}`. + + Example: + + * `fp audits assign <finding-id> --to alice@example.com` + """ + _triage(ctx, "assign", finding_id, assigned_to=assignee) + + +_AUDITS_GROUP_HELP = """Schedule audits over your agent activity and triage the findings they produce. + +An **audit** runs on a schedule and sweeps a window of activity; what it produces are **findings** — +recurring patterns carried across runs. Audits are referenced by **name**, findings by **id**. + +**Subcommands:** `list` · `show` · `create` · `edit` · `delete` · `run` · `runs` · `findings` · +`finding` · `ack` · `mute` · `dismiss` · `resolve` · `reopen` · `assign` + +**Examples:** + +* `fp audits list` — all audits, newest first +* `fp audits show nightly-prod` — one audit's schedule / scope / analysis / channels +* `fp audits create nightly-prod --scope '{"environments":["prod"]}'` +* `fp audits run nightly-prod` — queue a run now, ahead of schedule +* `fp audits findings --status open` — the live triage queue, highest priority first +* `fp audits finding <id>` — one finding in full, with its recommendation +* `fp audits resolve <id>` — you fixed it · `mute` / `dismiss` to suppress · `reopen` to undo +""" + + +def register(app: typer.Typer) -> None: + audits_app = typer.Typer( + no_args_is_help=True, + rich_markup_mode="markdown", + context_settings={"help_option_names": ["-h", "--help"]}, + help=_AUDITS_GROUP_HELP, + ) + audits_app.command("list", epilog=GLOBALS_EPILOG)(audits_list) + audits_app.command("show", epilog=GLOBALS_EPILOG)(audits_show) + audits_app.command("create", epilog=GLOBALS_EPILOG)(audits_create) + audits_app.command("edit", epilog=GLOBALS_EPILOG)(audits_edit) + audits_app.command("delete", epilog=GLOBALS_EPILOG)(audits_delete) + audits_app.command("run", epilog=GLOBALS_EPILOG)(audits_run) + audits_app.command("runs", epilog=GLOBALS_EPILOG)(audits_runs) + # Hyphenated rather than a nested `context` sub-group: the group's help is a + # hand-maintained table (output.py `_TOP_LEVEL_GROUPS`), and a third level + # would not render in it. + audits_app.command("context-show", epilog=GLOBALS_EPILOG)(audits_context_show) + audits_app.command("context-set", epilog=GLOBALS_EPILOG)(audits_context_set) + audits_app.command("context-refresh", epilog=GLOBALS_EPILOG)(audits_context_refresh) + audits_app.command("findings", epilog=GLOBALS_EPILOG)(audits_findings) + audits_app.command("finding", epilog=GLOBALS_EPILOG)(audits_finding) + audits_app.command("ack", epilog=GLOBALS_EPILOG)(findings_ack) + audits_app.command("mute", epilog=GLOBALS_EPILOG)(findings_mute) + audits_app.command("dismiss", epilog=GLOBALS_EPILOG)(findings_dismiss) + audits_app.command("resolve", epilog=GLOBALS_EPILOG)(findings_resolve) + audits_app.command("reopen", epilog=GLOBALS_EPILOG)(findings_reopen) + audits_app.command("assign", epilog=GLOBALS_EPILOG)(findings_assign) + app.add_typer(audits_app, name="audits") diff --git a/fp-cli/fp_cli/commands/auth_cmds.py b/fp-cli/fp_cli/commands/auth_cmds.py new file mode 100644 index 000000000..f9b40bfa1 --- /dev/null +++ b/fp-cli/fp_cli/commands/auth_cmds.py @@ -0,0 +1,473 @@ +"""login / logout / whoami.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import List, Optional, Tuple + +import typer + +from .. import _click_compat as click # the Click Typer is running; see _click_compat +from .. import analytics +from .. import auth +from .. import config as cfgmod +from .. import orgs as orgsmod +from .. import output +from .. import select as selectmod +from .._context import ( + GLOBALS_EPILOG, + AppState, + AuthMode, + build_context, + deny_in_key_mode, + resolved_base_url, +) +from ..client import ClientContext, get_session_user, org_is_accessible +from ..errors import ApiError, AuthError + + +def _resolve_login_org( + state: AppState, + requested: Optional[str], + slugs: List[str], + is_admin: bool, + saved: Optional[str] = None, + probe_ctx: Optional[ClientContext] = None, +) -> Tuple[Optional[str], bool]: + """Pick the active org at login. Returns ``(slug_or_None, needs_selection)``. + + ``requested`` is the **explicit** tenant only (login ``--org`` / global + ``--org`` / ``FP_ORG``) — NOT a previously-saved tenant. ``saved`` is the + last-used tenant from the config; it never bypasses the picker, it is only the + interactive default (Enter-to-keep) and the non-interactive fallback. + + Selection rules for a multi-org user with no explicit ``--org``: + * interactive TTY → always show the picker (defaulting to ``saved``), so the + user re-chooses every login rather than silently re-entering a stale tenant. + * non-interactive (``--json`` / piped stdin) → reuse a still-valid ``saved`` + tenant if present, else return ``needs_selection`` so the caller persists + the token, lists the orgs and exits non-zero for a script to handle. + """ + if requested: + if not orgsmod.is_valid_org_slug(requested): + raise click.BadParameter( + f"'{requested}' is not a valid org slug.", param_hint="--org" + ) + # Fast path: an org you're a member of is always fine (no server round-trip). + if requested in slugs: + return requested, False + # Not a membership. A regular user simply cannot use it. + if not is_admin: + raise click.BadParameter( + f"You are not a member of org '{requested}'. " + f"Your orgs: {', '.join(slugs) or '(none)'}.", + param_hint="--org", + ) + # Instance admin requesting a non-member org: allow ONLY if it actually + # EXISTS and is accessible — verified against the server. This closes the + # old `or is_admin` hole that accepted (and saved) any typo'd/nonexistent + # slug. Same check covers --org and FP_ORG (both feed `requested`). + if probe_ctx is not None and org_is_accessible(probe_ctx, requested): + return requested, False + raise click.BadParameter( + f"Org '{requested}' does not exist or you do not have access to it.", + param_hint="--org", + ) + if len(slugs) == 1: + return slugs[0], False + if not slugs: + return None, False # no memberships (e.g. instance admin) — pick per command with --org + # Multi-org, no explicit tenant requested. + if state.json or not selectmod.stdin_is_tty(): + # Can't prompt: reuse a still-valid saved tenant, else ask the caller to choose. + if saved and saved in slugs: + return saved, False + return None, True + return selectmod.choose_org(slugs, default=saved), False + + +def _looks_like_email(value: str) -> bool: + """A light shape check for the interactive email step — `x@y.z`, no spaces (the server is the + real authority; this just catches an obvious typo before sending a code).""" + s = (value or "").strip() + return "@" in s and " " not in s and "." in s.rsplit("@", 1)[-1] and len(s) >= 5 + + +def _login_interactive(state: AppState, base: str, email_opt: Optional[str], org_opt: Optional[str]) -> None: + """The single-box interactive login (real TTY only): one redrawn-in-place panel — email → code → + (org picker) → signed-in. Mirrors the non-interactive orchestration below; only the UI differs. + Network/auth errors propagate (terminal restored on the way out) to the standard red error box.""" + saved = state.config.org + persisted = False + with selectmod.LoginBox() as box: + try: + # email + if email_opt: + email = email_opt + box.note("email", email) + else: + email = box.text_step("email", validate=_looks_like_email, + error_msg="that doesn't look like an email") + # send the code + box.working("sending a code…") + auth.request_otp(base, email, timeout=state.timeout, verify=not state.insecure) + box.note("code sent") + # code → verify. A wrong/expired code is reported cleanly INSIDE the box (not a raw + # HTTP 500) with a relogin hint, then we exit — re-run `fp login` for a fresh code. + code = box.text_step("code", helper="enter the 6-digit code", slots=6, collapse=False) + box.working("verifying…") + try: + token, expires_in, user = auth.verify_otp( + base, email, code.strip(), timeout=state.timeout, verify=not state.insecure + ) + except AuthError: + box.fail("that code didn't match or has expired", "sign in again with fp login") + raise typer.Exit(code=4) + except ApiError as exc: # e.g. too many attempts (429) + box.fail(exc.message, "try fp login again in a bit") + raise typer.Exit(code=exc.exit_code) + box.note("code") # collapse to `✓ code` — never echo the value + # persist first (so we can read authoritative memberships), then resolve the org + auth.persist_session(state.config, base, token, expires_in, user, insecure=state.insecure) + persisted = True + analytics.identify(state.config.user_id) + sess_ctx = ClientContext(base_url=base, token=token, timeout=state.timeout, verify=not state.insecure) + slugs: List[str] = [] + is_admin = False + try: + su = get_session_user(sess_ctx) + slugs = su.org_slugs + is_admin = su.is_instance_admin + except Exception: + pass + requested = org_opt or state.org_explicit + if requested: + chosen, _ = _resolve_login_org(state, requested, slugs, is_admin, saved=saved, probe_ctx=sess_ctx) + if chosen: + box.note("org", chosen) + elif len(slugs) > 1: + chosen = box.pick(slugs, default=saved if saved in slugs else None) + else: + chosen = slugs[0] if slugs else None # single/none → named only in the final block + state.config.org = chosen + cfgmod.save_config(state.config) + analytics.capture("logged_in") # also count the interactive (real-TTY) login path + who = user.get("email") or email + box.finish(who, chosen) + except selectmod.LoginCancelled: + box.cancel(persisted) # calm close inside the same frame + + +def login( + ctx: typer.Context, + email: Optional[str] = typer.Option(None, "--email", "-e", help="Email to send the login code to (prompted if omitted)."), + org: Optional[str] = typer.Option(None, "--org", help="Org/tenant slug to sign in to — skips the picker. Must be one you can access."), + force: bool = typer.Option(False, "--force", help="Re-authenticate even if you already have a valid session."), +) -> None: + """Sign in to the dashboard with an emailed one-time code. + + You enter your email (or pass `--email`), the dashboard emails a 6-digit code, and you paste + it back. The session is saved to `~/.failproofai/fpcli/cli-auth.json` (mode 0600) and lasts ~24h — just + re-run `login` when it expires. Already signed in? `login` shows who you are and exits 0 + without prompting; pass `--force` to re-authenticate anyway. + + The active org is chosen here and saved for later commands. With one org it's picked + automatically; with several you choose from a list once your email is verified (your last-used + org is the Enter-to-keep default), or pass `--org <slug>` to skip the picker. Switch it later + with `fp orgs switch <slug>`. + + Both prompts are skippable: `--email` / `--org` provide the values up front, otherwise you're + asked interactively. First time, set the dashboard URL with the global `--base-url` (saved for + next time); add the global `--insecure` for a self-signed/internal dashboard. + + With `--json`: `{"logged_in": true, "email": "...", "org": "<slug>", "expires_in_secs": <n>}`. + Exit `0` on success; `2` if sign-in worked but the org is still unresolved (multi-org, no + `--org`, non-interactive) — the token is saved, so re-run with `--org <slug>`. + + Examples: + + * `fp login` — fully interactive: prompts for your email, then the org picker + * `fp --base-url https://fp.example.com login` — first time: set the dashboard URL, then sign in interactively + * `fp login --email you@example.com --org acme` — skip both prompts (email + org up front) + * `fp --base-url https://dash.internal --insecure login` — self-signed / internal dashboard + """ + state: AppState = ctx.obj + deny_in_key_mode( + state, + "login", + "it signs a human in and saves the session, and an API key already IS the " + "credential — there is nothing to sign in as", + ) + # Already signed in with a still-valid session? Don't silently start a second login — + # but DO honor an explicit selector instead of dropping it: a different `--email` means + # "sign in as someone else" (fall through to a full re-auth); an explicit `--org` that + # differs from the active one switches the tenant on the existing session (no re-auth). + # `--force` overrides everything. Otherwise, just report who you are. + if not force and not cfgmod.is_expired(state.config): + requested = org or state.org_explicit + switching_account = bool(email) and email != state.config.email + if not switching_account: + if requested and requested != state.config.org: + base = resolved_base_url(state) + sess_ctx = ClientContext( + base_url=base, token=state.config.session_token, + timeout=state.timeout, verify=not state.insecure, + ) + # Read memberships to validate the requested org. Unlike the post-OTP flow (where + # memberships are best-effort), here a failed read must surface — otherwise an + # empty membership set would mis-reject a valid org as "not a member". A dead/ + # expired session (AuthError) or outage (NetworkError) propagates to the chokepoint. + su = get_session_user(sess_ctx) + slugs, is_admin = su.org_slugs, su.is_instance_admin + # Validates membership / admin-accessibility (raises a clean usage error on a + # bad slug) exactly like a fresh login, then persists the new active tenant. + chosen, _ = _resolve_login_org( + state, requested, slugs, is_admin, saved=state.config.org, probe_ctx=sess_ctx + ) + state.config.org = chosen + cfgmod.save_config(state.config) + analytics.capture("org_selected") # name-only (no slug value) + if state.json: + output.emit_json({ + "logged_in": True, "email": state.config.email, "org": chosen, + "already_signed_in": True, "switched_org": True, + }) + else: + output.signed_in(state.config.email, chosen) + return + if state.json: + output.emit_json( + { + "logged_in": True, + "email": state.config.email, + "org": state.config.org, + "already_signed_in": True, + } + ) + else: + output.already_signed_in(state.config.email, state.config.org) + return + base = resolved_base_url(state) + # On a real TTY (not --json / pipes / CI) run the single-box interactive flow; everything + # else keeps the plain prompt flow below (which the test runner + scripts rely on). + if not state.json and selectmod.login_box_supported(): + _login_interactive(state, base, email, org) + return + output.auth_header() + if not email: + email = output.prompt("email") + auth.request_otp(base, email, timeout=state.timeout, verify=not state.insecure) + output.code_sent(email) + code = output.prompt("code") + token, expires_in, user = auth.verify_otp( + base, email, str(code).strip(), timeout=state.timeout, verify=not state.insecure + ) + # Persist the token first so we can query the session for authoritative memberships: + # the OTP-verify payload is slim (no memberships / is_instance_admin) — only + # GET /api/auth/session returns them. + auth.persist_session(state.config, base, token, expires_in, user, insecure=state.insecure) + analytics.identify(state.config.user_id) + # One context, reused to read memberships AND to validate an explicit --org. + sess_ctx = ClientContext( + base_url=base, token=token, timeout=state.timeout, verify=not state.insecure + ) + slugs: List[str] = [] + is_admin = False + try: + su = get_session_user(sess_ctx) + slugs = su.org_slugs + is_admin = su.is_instance_admin + except Exception: + pass + # The active tenant at login is an EXPLICIT choice only: the `login --org` + # flag, the global `--org`, or FP_ORG. A previously *saved* tenant must + # not silently bypass the picker — a multi-org user re-running `login` is shown + # their orgs and chooses (the saved one is just the default). `state.org` + # (flag > env > config) is NOT used here precisely because it folds in config. + requested = org or state.org_explicit + saved = state.config.org + chosen, needs_selection = _resolve_login_org( + state, requested, slugs, is_admin, saved=saved, probe_ctx=sess_ctx + ) + state.config.org = chosen # persist the active tenant (or clear it if unresolved) + cfgmod.save_config(state.config) + who = user.get("email") or email + + if needs_selection: + # Multi-org, non-interactive, no --org: token is saved but no tenant is active. + if state.json: + output.emit_json( + { + "logged_in": True, + "email": who, + "org": None, + "expires_in_secs": expires_in, + "needs_org_selection": True, + "orgs": slugs, + } + ) + else: + output.warn( + "Logged in, but you belong to multiple orgs and none was selected." + ) + output.info("Your orgs: " + ", ".join(slugs)) + output.hint("Re-run with --org <slug> (e.g. `fp login --org " + f"{slugs[0]}`) or `fp orgs switch <slug>`.") + raise typer.Exit(code=2) + + analytics.capture("logged_in") # name-only business event (no email/id/org value) + if state.json: + output.emit_json( + {"logged_in": True, "email": who, "org": chosen, "expires_in_secs": expires_in} + ) + else: + output.signed_in(who, chosen) + + +def logout(ctx: typer.Context) -> None: + """Sign out and clear the saved session from this machine. + + Best-effort server-side revocation, then wipes the session from + `~/.failproofai/fpcli/cli-auth.json` — + the token, your email and user id, and the active org — so nothing about who you were or + which tenant you used is left behind. Your `base_url` and `--insecure` preference are kept, + so the next `login` is quick. If you are not signed in it is a no-op that reports + "already signed out". + + With `--json`: `{"logged_out": true}` (plus `"already_signed_out": true` when there was no + session to clear). + + Example: + + * `fp logout` + """ + state: AppState = ctx.obj + deny_in_key_mode( + state, + "logout", + "an API key is never saved to disk, so there is nothing here to clear — and " + "revoking one takes `keys disable`, a permission a scoped key does not hold", + ) + # No active session → nothing to revoke. Don't falsely claim a sign-out happened. + if not state.token: + if state.json: + output.emit_json({"logged_out": True, "already_signed_out": True}) + else: + output.already_signed_out() + return + if state.base_url: + auth.logout(state.base_url, state.token, timeout=state.timeout, verify=not state.insecure) + analytics.capture("logged_out") # name-only; fire before reset() rotates the anon id + cfgmod.clear_token(state.config) + analytics.reset() + if state.json: + output.emit_json({"logged_out": True}) + else: + output.signed_out() + + +def whoami(ctx: typer.Context) -> None: + """Show the **current user**, the active org, and that org's permissions. + + Never errors on a missing/expired session — it reports "not logged in" instead, so + it is safe for agents to probe auth state. Permissions are **per org**: the + `permissions` shown are for the active org. With `--json`, logged in: + `{"logged_in": true, "auth_mode": "session", "id": "...", "email": "...", + "is_instance_admin": <bool>, "active_org": "<slug|null>", "permissions": [...], + "memberships": [...]}`; otherwise `{"logged_in": false, "auth_mode": "none"}`. + + With an API key it is the one command that still works, and it reports an honest + different shape — `{"logged_in": false, "auth_mode": "api_key", "active_org": + "<slug|null>"}`. A `null` there is worth reading: an instance-scoped key with no + `--org` resolves server-side to the DEFAULT org, so you would get *an* org's data, + just not necessarily the one you meant, with no error anywhere. + + Example: + + * `fp whoami` + * `fp --json whoami` + """ + state: AppState = ctx.obj + if state.auth_mode is AuthMode.API_KEY: + # `whoami` is contractually "never errors" (cli/skill/SKILL.md leans on it as + # the pre-flight probe), so key mode reports rather than refuses — but it does + # NOT invent a user: a key has no identity the CLI can read, and no /v1 + # endpoint would tell us. Report exactly what we know, locally, exit 0. + if state.json: + output.emit_json( + { + "logged_in": False, + "auth_mode": AuthMode.API_KEY.value, + "active_org": state.org_explicit, + } + ) + else: + output.render_key_mode_whoami(state.org_explicit) + return + if not state.token: + if state.json: + output.emit_json({"logged_in": False, "auth_mode": AuthMode.NONE.value}) + else: + output.not_signed_in() + return + try: + user = get_session_user(build_context(state)) + except AuthError: + if state.json: + output.emit_json({"logged_in": False, "auth_mode": AuthMode.NONE.value}) + else: + output.not_signed_in() + return + + # Effective active org: explicit/global/saved, else the sole membership. + active = state.org + if not active and len(user.memberships) == 1: + active = user.memberships[0].org_slug + active_perms = user.permissions_for(active) + + if state.json: + output.emit_json( + { + "logged_in": True, + "auth_mode": AuthMode.SESSION.value, + "id": user.id, + "email": user.email, + "is_instance_admin": user.is_instance_admin, + "active_org": active, + "permissions": active_perms, + "memberships": [asdict(m) for m in user.memberships], + } + ) + return + + # Scannable identity view: a small header, then a permissions panel + an orgs panel. + orgs = [ + { + "slug": m.org_slug, + "name": m.org_name, + "role": m.permission_set or "custom", + "perms": len(m.permissions), + "is_active": m.org_slug == active, + } + for m in user.memberships + ] + active_role = next((o["role"] for o in orgs if o["is_active"]), None) + output.render_whoami( + email=user.email, + is_instance_admin=user.is_instance_admin, + user_id=user.id, + active_org=active, + active_role=active_role, + permissions=active_perms, + orgs=orgs, + ) + + +# whoami's relevant global flag is `--json`; the full global-options list is omitted here. +_WHOAMI_EPILOG = "The global `--json` option goes **before** the command: `fp --json whoami`." + + +def register(app: typer.Typer) -> None: + app.command("login", epilog=GLOBALS_EPILOG)(login) + app.command("logout", epilog=GLOBALS_EPILOG)(logout) + app.command("whoami", epilog=_WHOAMI_EPILOG)(whoami) diff --git a/fp-cli/fp_cli/commands/errors_cmds.py b/fp-cli/fp_cli/commands/errors_cmds.py new file mode 100644 index 000000000..2424fe6e2 --- /dev/null +++ b/fp-cli/fp_cli/commands/errors_cmds.py @@ -0,0 +1,168 @@ +"""errors — list errored events, or --aggregate them into a summary card. + +Read-only; talks to the dashboard `/api/events/summary` (the light, payload-free errored +rows) and `/api/events/error_summary` (the aggregate). The list view renders the server's +precomputed `summary` column — the CLI never returns the fat payload for an errors read. +Free-text `--search` remains the exception at the database layer: it scans payload to match. +Emits the server payload verbatim under `--json`. +""" + +from __future__ import annotations + +from typing import List, Optional + +import typer + +from .. import client as api +from .. import dates, output +from .._context import ( + GLOBALS_EPILOG, + AppState, + require_auth, + resolve_dates, + resolve_fields, + validate_choice, + validate_limit, +) +from ..models import AgentEvent + + +def errors( + ctx: typer.Context, + aggregate: bool = typer.Option(False, "--aggregate", help="Summarise the matching errors into a card (count + sessions/agents/last seen) instead of listing them."), + limit: int = typer.Option(50, "--limit", "-n", help="Max rows in total (list mode). Use --all to auto-paginate beyond the server's single-request cap."), + since: Optional[str] = typer.Option(None, "--since", help=f"Relative window from now (dashboard presets): {', '.join(dates.SINCE_CHOICES)}."), + ts_from: Optional[str] = typer.Option(None, "--from", help="Custom-range start, ISO-8601 UTC (e.g. 2026-05-01T00:00:00Z). Overrides --since."), + ts_to: Optional[str] = typer.Option(None, "--to", help="Custom-range end, ISO-8601 UTC. Overrides --since."), + environment: Optional[str] = typer.Option(None, "--env", help="Filter to one environment (exact match). e.g. `prod`."), + error_type: Optional[str] = typer.Option(None, "--error-type", help="Filter to one error type (exact match). e.g. `TimeoutError`."), + event_type: Optional[str] = typer.Option(None, "--event-type", help="Filter to one event type (exact match). e.g. `error`."), + agent_id: Optional[str] = typer.Option(None, "--agent-id", help="Filter to one agent id (exact match)."), + session_id: Optional[str] = typer.Option(None, "--session-id", help="Filter to one session id (exact match)."), + search: Optional[List[str]] = typer.Option(None, "--search", help="Free-text term to match in the payload (repeatable; an event matches if it contains ANY of the terms)."), + order: Optional[str] = typer.Option(None, "--order", help="Sort order by time: `asc` or `desc` (default newest-first; list mode)."), + fetch_all: bool = typer.Option(False, "--all", help="Auto-paginate through all pages, up to --limit (list mode)."), + cursor: Optional[str] = typer.Option(None, "--cursor", help="Resume after this cursor (a prior next_cursor; opaque token; list mode)."), + page_size: Optional[int] = typer.Option(None, "--page-size", help="Rows per request when --all (max 200; list mode)."), + fields: Optional[str] = typer.Option(None, "--fields", help="Comma-separated subset of fields to output (list mode). e.g. `ts,event_type,session_id`."), + full_ids: bool = typer.Option(False, "--full-ids", help="Show full session ids in the table instead of truncating them (list mode; --json always has the full id)."), +) -> None: + """List **errored events**, newest first — or roll them up with **`--aggregate`**. + + Errored events are the failures across your agents' runs (the dashboard's `/errors` view). + Two modes, same filters: + + * **list** (default) — one row per errored event: `time · event · env · agent · session · + summary` (the summary is derived from the event payload). + * **`--aggregate`** — a summary card: the total error count plus how many sessions and agents + are affected and how recent the last error is. + + Each filter — `--env`, `--error-type`, `--event-type`, `--agent-id`, `--session-id` — takes a + **single** value; combine them to narrow to one slice (they AND together). `--search` matches + a free-text term in the event payload (repeatable — an event matches ANY of the terms). Scope + time with `--since` or `--from`/`--to`. Every filter applies to both modes. + + Needs `events:read`. List `--json`: `{"errors": [...], "next_cursor": …}` — each row is a + light, payload-free event (`id, session_id, agent_id, event_type, ts, environment, + summary, is_error, error_type, output_tokens`, also the valid `--fields` names). The + rendered `summary` is the server-computed `summary` field (no client-side payload + parsing). Aggregate `--json`: `{total, sessions, agents, last_ts, bins}`. For the raw + payload of an errored run, use `fp events --full --session-id <id>`. + + Examples: + + * `fp errors --env prod --since 24h` — recent production errors + * `fp errors --error-type TimeoutError --agent-id agent-orderbot` — one agent's timeouts + * `fp errors --aggregate --env prod --since 7d` — how many prod errors this week, and where + * `fp --json errors --session-id sess-001 --all | jq '.errors[].summary'` — one session's error summaries as JSON + """ + state: AppState = ctx.obj + cctx = require_auth(state) + validate_limit(limit) + order = validate_choice(order, ("asc", "desc"), flag="--order") + frm, to = resolve_dates(since, ts_from, ts_to) + + # Which narrowing filters did the user set? Used to word the empty message and to nudge them + # to re-check those values when nothing matches (a value matching nothing returns empty, not + # an error — so a typo reads as "no errors"). Applies to both modes. + active_filters = [ + flag for flag, val in ( + ("--env", environment), + ("--event-type", event_type), + ("--error-type", error_type), + ("--agent-id", agent_id), + ("--session-id", session_id), + ("--search", search), + ("--since/--from/--to", since or ts_from or ts_to), + ) if val + ] + + # ── aggregate mode: one summary payload, no pagination ── + if aggregate: + data = api.event_error_summary( + cctx, + session_id=session_id, + agent_id=agent_id, + event_type=event_type, + error_type=error_type, + environment=environment, + search=search, + ts_from=frm, + ts_to=to, + ) + if state.json: + output.emit_json(data) + else: + output.render_error_aggregate(data) + if not int(data.get("total", 0) or 0): + output.recheck_filters_hint(active_filters) + return + + # ── list mode: errored events (errored=true, like the dashboard /errors view) ── + cols = resolve_fields(fields, AgentEvent) + common = dict( + session_id=session_id, + agent_id=agent_id, + event_type=event_type, + error_type=error_type, + environment=environment, + errored=True, + order=order, + search=search, + ts_from=frm, + ts_to=to, + ) + + if fetch_all: + items = list( + api.paginate( + lambda cursor, limit: api.list_event_summaries(cctx, cursor=cursor, limit=limit, **common), + limit=limit, + page_size=page_size, + start_cursor=cursor, + ) + ) + next_cursor = None + else: + page = api.list_event_summaries(cctx, cursor=cursor, limit=limit, **common) + items = page.items + next_cursor = page.next_cursor + + if state.json: + payload = output.project_dicts(items, cols) if cols else items + output.emit_json({"errors": payload, "next_cursor": next_cursor}) + return + + if cols: + # `--fields` asks for specific raw columns → the generic table (no bespoke styling). + output.print_table(list(cols), output.project_rows(items, cols), title=f"Errors ({len(items)})") + else: + empty_message = "no errors match these filters" if active_filters else "no errors" + output.render_errors(items, order=order, full_ids=full_ids, empty_message=empty_message) + output.errors_footer(len(items), more=next_cursor is not None) + if not items: + output.recheck_filters_hint(active_filters) + + +def register(app: typer.Typer) -> None: + app.command("errors", epilog=GLOBALS_EPILOG)(errors) diff --git a/fp-cli/fp_cli/commands/evals_cmds.py b/fp-cli/fp_cli/commands/evals_cmds.py new file mode 100644 index 000000000..f8c30b010 --- /dev/null +++ b/fp-cli/fp_cli/commands/evals_cmds.py @@ -0,0 +1,154 @@ +"""evals — list evaluation results (with scores), or roll them up with --aggregate.""" + +from __future__ import annotations + +from typing import List, Optional + +import typer + +from .. import client as api +from .. import dates, output +from .._context import ( + GLOBALS_EPILOG, + AppState, + require_auth, + resolve_dates, + resolve_fields, + validate_choice, + validate_limit, + validate_score_filters, +) +from ..models import Evaluation + + +def evals( + ctx: typer.Context, + aggregate: bool = typer.Option(False, "--aggregate", help="Roll the matching evaluations up into a totals card + per-metric score stats, instead of listing rows."), + limit: int = typer.Option(50, "--limit", "-n", help="Max rows in total (list mode). Use --all to auto-paginate beyond the server's single-request cap."), + since: Optional[str] = typer.Option(None, "--since", help=f"Relative window from now (dashboard presets): {', '.join(dates.SINCE_CHOICES)}."), + ts_from: Optional[str] = typer.Option(None, "--from", help="Custom-range start, ISO-8601 UTC (e.g. 2026-05-01T00:00:00Z). Overrides --since."), + ts_to: Optional[str] = typer.Option(None, "--to", help="Custom-range end, ISO-8601 UTC. Overrides --since."), + environment: Optional[str] = typer.Option(None, "--env", help="Filter to one environment (exact match). e.g. `prod`."), + status: Optional[str] = typer.Option(None, "--status", help="Filter to one evaluation status: `done`, `error`, or `timeout`."), + agent_id: Optional[str] = typer.Option(None, "--agent-id", help="Filter to one agent id (exact match)."), + session_id: Optional[str] = typer.Option(None, "--session-id", help="Filter to one session id (exact match)."), + score: Optional[List[str]] = typer.Option(None, "--score", help="Score range filter `KEY:MIN..MAX` (either bound optional). Repeatable; all must match. e.g. `helpfulness:0.5..0.8`."), + fetch_all: bool = typer.Option(False, "--all", help="Auto-paginate through all pages, up to --limit (list mode)."), + cursor: Optional[str] = typer.Option(None, "--cursor", help="Resume after this cursor (a prior next_cursor; opaque token; list mode)."), + page_size: Optional[int] = typer.Option(None, "--page-size", help="Rows per request when --all (max 200; list mode)."), + fields: Optional[str] = typer.Option(None, "--fields", help="Comma-separated subset of fields to output (list mode). e.g. `session_id,status,scores`."), + full_ids: bool = typer.Option(False, "--full-ids", help="Show full session ids in the table instead of truncating them (list mode; --json always has the full id)."), + scores_full: bool = typer.Option(False, "--scores-full", help="Show every score pair in the table instead of the first few + `+N` (list mode; may wrap)."), +) -> None: + """List **evaluation results** with their scores, newest first — or roll them up with + **`--aggregate`**. An evaluation is one scored judgement of an agent run. + + Two modes, same filters: + + * **list** (default) — one row per evaluation: `time · env · agent · session · status · scores`. + * **`--aggregate`** — a totals card (run-health mix + success rate) and a per-metric + score-stats table (count, average + bar, min/max/p50) over the whole matching set, worst + average first. Point it at one slice — an agent, env, session, or status — to read that + slice's score performance. + + The filters `--env`, `--status`, `--agent-id`, and `--session-id` each take a **single** + value; combine them to narrow a slice (they AND together). `--score KEY:MIN..MAX` filters by + score range — either bound optional (`helpfulness:0.5..0.8`, `tool_efficiency:..0.3`, + `factuality:0.9..`) — and is **repeatable**, with **all** ranges required (AND). Scope time + with `--since` or `--from`/`--to`. Every filter applies to both modes. + + Needs `evaluations:read`. List `--json`: `{"evaluations": [...], "next_cursor": …}`. + Aggregate `--json`: `{total, status_counts, score_stats[], timeline}`. + + Examples: + + * `fp evals --agent-id agent-orderbot --aggregate` — one agent's score performance, rolled up + * `fp evals --aggregate --env prod --status error` — score stats for prod runs that failed + * `fp evals --score helpfulness:0.8.. --since 7d` — recent evaluations scoring ≥0.8 on helpfulness + * `fp --json evals --aggregate --session-id sess-001 | jq '.score_stats'` — one session's metric stats as JSON + """ + state: AppState = ctx.obj + cctx = require_auth(state) + validate_score_filters(score) + validate_limit(limit) + status = validate_choice(status, ("done", "error", "timeout"), flag="--status") + frm, to = resolve_dates(since, ts_from, ts_to) + score_filters = ",".join(score) if score else None + + # Which narrowing filters did the user set? Used to word the empty message and to nudge + # them to re-check those values when nothing matches (a value matching nothing returns an + # empty set, not an error — so a typo looks like "no evals"). Applies to both modes. + active_filters = [ + flag for flag, val in ( + ("--env", environment), + ("--status", status), + ("--agent-id", agent_id), + ("--session-id", session_id), + ("--score", score), + ("--since/--from/--to", since or ts_from or ts_to), + ) if val + ] + + # ── aggregate mode: one rolled-up payload, no pagination ── + if aggregate: + data = api.evaluation_aggregate( + cctx, + session_id=session_id, + agent_id=agent_id, + environment=environment, + status=status, + score_filters=score_filters, + ts_from=frm, + ts_to=to, + ) + if state.json: + output.emit_json(data) + else: + output.render_eval_aggregate(data) + if not int(data.get("total", 0) or 0): + output.recheck_filters_hint(active_filters) + return + + # ── list mode ── + cols = resolve_fields(fields, Evaluation) + + def fetch(cursor: Optional[int], limit: Optional[int]): + return api.list_evaluations( + cctx, + session_id=session_id, + agent_id=agent_id, + environment=environment, + status=status, + score_filters=score_filters, + ts_from=frm, + ts_to=to, + cursor=cursor, + limit=limit, + ) + + if fetch_all: + items = list(api.paginate(fetch, limit=limit, page_size=page_size, start_cursor=cursor)) + next_cursor = None + else: + page = fetch(cursor, limit) + items = page.items + next_cursor = page.next_cursor + + if state.json: + payload = output.project_dicts(items, cols) if cols else items + output.emit_json({"evaluations": payload, "next_cursor": next_cursor}) + return + + if cols: + # `--fields` asks for specific columns → the generic table (no bespoke styling). + output.print_table(list(cols), output.project_rows(items, cols), title=f"Evaluations ({len(items)})") + else: + empty_message = "no evals match these filters" if active_filters else "no evals" + output.render_evals(items, full_ids=full_ids, scores_full=scores_full, empty_message=empty_message) + output.evals_footer(len(items), more=next_cursor is not None) + if not items: + output.recheck_filters_hint(active_filters) + + +def register(app: typer.Typer) -> None: + app.command("evals", epilog=GLOBALS_EPILOG)(evals) diff --git a/fp-cli/fp_cli/commands/events_cmds.py b/fp-cli/fp_cli/commands/events_cmds.py new file mode 100644 index 000000000..dd59d5f99 --- /dev/null +++ b/fp-cli/fp_cli/commands/events_cmds.py @@ -0,0 +1,153 @@ +"""events — list raw agent events.""" + +from __future__ import annotations + +from typing import List, Optional + +import typer + +from .. import client as api +from .. import dates, output +from .._context import ( + GLOBALS_EPILOG, + AppState, + collect_multi, + require_auth, + resolve_dates, + resolve_fields, + validate_limit, +) +from ..models import AgentEvent + + +def events( + ctx: typer.Context, + limit: int = typer.Option(50, "--limit", "-n", help="Max rows in total. Use --all to auto-paginate beyond the server's single-request cap."), + since: Optional[str] = typer.Option(None, "--since", help=f"Relative window from now (dashboard presets): {', '.join(dates.SINCE_CHOICES)}."), + ts_from: Optional[str] = typer.Option(None, "--from", help="Custom-range start, ISO-8601 UTC (e.g. 2026-05-01T00:00:00Z). Overrides --since."), + ts_to: Optional[str] = typer.Option(None, "--to", help="Custom-range end, ISO-8601 UTC. Overrides --since."), + environment: Optional[List[str]] = typer.Option(None, "--env", help="Filter by environment. Accepts multiple — repeat the flag or comma-separate: `--env prod --env staging` or `--env prod,staging` (matches any)."), + event_type: Optional[List[str]] = typer.Option(None, "--event-type", help="Filter by event type. Accepts multiple — repeat the flag or comma-separate: `--event-type tool_use,tool_result` (matches any). Discover values with `fp list event_types`."), + agent_id: Optional[List[str]] = typer.Option(None, "--agent-id", help="Filter by agent id. Accepts multiple — repeat the flag or comma-separate: `--agent-id a,b` (matches any). Discover values with `fp list agents`."), + session_id: Optional[List[str]] = typer.Option(None, "--session-id", help="Filter by session id. Accepts multiple — repeat the flag or comma-separate: `--session-id a,b` (matches any)."), + search: Optional[List[str]] = typer.Option(None, "--search", help="Free-text term to match in the payload (repeatable; an event matches if it contains ANY of the terms)."), + order: Optional[str] = typer.Option(None, "--order", help="Sort order by time: `asc` or `desc` (default newest-first)."), + fetch_all: bool = typer.Option(False, "--all", help="Auto-paginate through all pages, up to --limit."), + cursor: Optional[str] = typer.Option(None, "--cursor", help="Resume after this cursor (a prior next_cursor; opaque token)."), + page_size: Optional[int] = typer.Option(None, "--page-size", help="Rows per request when --all (max 200)."), + full: bool = typer.Option(False, "--full", help="Fetch the FULL event rows incl. the raw `payload` (the heavy feed). Off by default — the light payload-free feed is used unless you pass this or request `--fields payload`. The full feed is slow at scale, so keep it bounded (e.g. to one `--session-id`)."), + fields: Optional[str] = typer.Option(None, "--fields", help="Comma-separated subset of fields to output (applies to --json and the table). e.g. `ts,event_type,summary`. Requesting `payload` auto-switches to the full feed."), +) -> None: + """List **event logs** — the raw, per-step trail your agents emit (tool calls, model + requests/responses, hooks, results), newest first. + + Narrow the feed with the filters below. `--env`, `--event-type`, `--agent-id`, and + `--session-id` each accept **multiple values** — repeat the flag or comma-separate + (`--env prod,staging` is the same as `--env prod --env staging`). Values within one + filter match **any** of them; different filters are combined (an event must satisfy all + of them). Scope by time with `--since` (a preset window) or `--from`/`--to` (a custom + UTC range), and walk large result sets with `--all` (or resume with `--cursor`). + + Discover valid filter values with `fp list` — e.g. `fp list event_types`, + `fp list agents`, `fp list envs`. + + Needs `events:read`. With `--json`: `{"events": [...], "next_cursor": <cursor or null>}`. + + By **default** this reads the light, payload-free feed — each event has `id, session_id, + agent_id, event_type, ts, environment, summary, is_error, error_type, output_tokens, + context_window, context_fill` (a server-computed one-line `summary`, never the raw + payload). This is the fast path for structured filters, `--session-id`, and `--all`. + `--search` keeps the response payload-free but still scans payload server-side, so broad + searches may be expensive. To get + the raw `payload`, opt into the **full feed** with `--full` (or `--fields payload`), which + hits the heavy `/events` endpoint — slow at scale, so keep it bounded (pair `--full` with a + single `--session-id`). All listed keys are valid `--fields` names (`payload` too, in full + mode). + + Examples: + + * `fp events --env prod,staging --event-type tool_use,error --limit 100` — recent prod/staging events (light) + * `fp --json events --session-id run-001 --all` — a run's full timeline: event types + summaries (light, fast) + * `fp --json events --full --session-id run-001 --all | jq '.events[].payload'` — that run's raw payloads (full feed, bounded to one run) + """ + state: AppState = ctx.obj + cctx = require_auth(state) + frm, to = resolve_dates(since, ts_from, ts_to) + cols = resolve_fields(fields, AgentEvent) + if order is not None and order not in ("asc", "desc"): + raise typer.BadParameter("--order must be 'asc' or 'desc'.") + validate_limit(limit) + + # Multi-value filters: merge repeated flags + comma-separated values into one flat, + # de-duplicated list each (the server UNIONs within a filter via `IN`, ANDs across them). + session_id = collect_multi(session_id) + agent_id = collect_multi(agent_id) + event_type = collect_multi(event_type) + environment = collect_multi(environment) + + # Default to the light, payload-free feed (/api/events/summary). Only reach for the heavy + # full feed (/api/events, with `payload`) when the caller EXPLICITLY asks for the raw + # payload: --full, or --fields payload. Everything else — including --session-id — stays + # on the light feed: the full feed is slow/timeout-prone at scale, a single-session read + # wants the summaries/timeline (not the fat payload), and payload is a deliberate opt-in. + use_full = full or (cols is not None and "payload" in cols) + fetch = api.list_events if use_full else api.list_event_summaries + + common = dict( + session_id=session_id, + agent_id=agent_id, + event_type=event_type, + environment=environment, + order=order, + search=search, + ts_from=frm, + ts_to=to, + ) + + if fetch_all: + items = list( + api.paginate( + lambda cursor, limit: fetch(cctx, cursor=cursor, limit=limit, **common), + limit=limit, + page_size=page_size, + start_cursor=cursor, + ) + ) + next_cursor = None + else: + page = fetch(cctx, cursor=cursor, limit=limit, **common) + items = page.items + next_cursor = page.next_cursor + + if state.json: + payload = output.project_dicts(items, cols) if cols else items + output.emit_json({"events": payload, "next_cursor": next_cursor}) + return + + # Which narrowing filters did the user actually set? Used to (a) word the empty-box + # message and (b) nudge them to re-check those values when 0 rows come back — the server + # returns an empty set for any value that matches nothing, so a typo looks like "no data". + active_filters = [ + flag for flag, val in ( + ("--env", environment), + ("--event-type", event_type), + ("--agent-id", agent_id), + ("--session-id", session_id), + ("--search", search), + ("--since/--from/--to", since or ts_from or ts_to), + ) if val + ] + + if cols: + # `--fields` asks for specific columns → the generic table (no bespoke styling). + output.print_table(list(cols), output.project_rows(items, cols), title=f"Events ({len(items)})") + else: + empty_message = "no events match these filters" if active_filters else "no events in this window" + output.render_events(items, order=order, empty_message=empty_message) + output.events_footer(len(items), more=next_cursor is not None) + if not items: + output.recheck_filters_hint(active_filters) + + +def register(app: typer.Typer) -> None: + app.command("events", epilog=GLOBALS_EPILOG)(events) diff --git a/fp-cli/fp_cli/commands/fleet_cmds.py b/fp-cli/fp_cli/commands/fleet_cmds.py new file mode 100644 index 000000000..367e59ed6 --- /dev/null +++ b/fp-cli/fp_cli/commands/fleet_cmds.py @@ -0,0 +1,416 @@ +"""The fleet: fleet list / show / deploy / diff / history / rollback / rename. + +What each machine is TOLD to enforce. Authoring the policies is `fp policies`; +what they actually did is `fp guardrails`. + +## The one thing to understand before reading `deploy` + +`PUT /enforcement/deployments/{id}` REPLACES a machine's whole policy set. There +is no merge and no server-side lock. The dashboard deliberately has no deploy +form for this reason — it edits the machine's own current set instead, because a +form that asks you to re-tick policies silently drops whatever you forget. + +So `deploy` here defaults to a read-modify-write: it reads what the machine runs, +applies `--add`/`--remove`, shows the resulting FULL set, and writes that. +`--set` is the escape hatch for the declarative case and is the only way to say +"exactly these, drop the rest". +""" +from __future__ import annotations + +from typing import List, Optional + +import typer + +from .. import client as api +from .. import output +from .._context import GLOBALS_EPILOG, AppState, deny_in_key_mode, require_auth +from .. import _click_compat as click # the Click Typer is running; see _click_compat +from ..enforcement import ( + RefError, + RefUsageError, + check_race, + disabled_ids, + latest_versions, + plan_deploy, +) +from ..errors import ApiError, NotFoundError +from . import _write + +_KEY_MODE_REASON = ( + "the fleet is an operator surface and is not exposed on the versioned API that " + "an API key authenticates against" +) + + +def _require_machine(cctx, machine_id: str) -> None: + """Refuse an id no machine has ever reported under. + + Without this, a typo is indistinguishable from a real machine that simply + has nothing deployed: both render an empty set and exit 0. The id is also + interpolated into a URL path further down, so an id containing `/` would + address a different route entirely — the server rejects those, but a clear + "no machine" beats someone else's 404. + """ + if machine_id not in {m.machine_id for m in api.list_machines(cctx)}: + raise NotFoundError(f"no machine {machine_id!r} has checked in") + + +def fleet_list(ctx: typer.Context) -> None: + """List machines and how many policies each is told to run. + + Shows `machine · label · pol · intended · applied · seen · events · state`. + `intended` is the generation deployed, `applied` is the one the machine last + collected, and `seen` is when it last reported anything — a machine can be + in sync and dead, or alive and behind, and those are different problems. + + A machine appears from its very first check-in, including the poll that + finds nothing deployed — that is exactly the machine you are usually looking + for. Needs `policies:read`. With `--json`: `{machines, deployments}`, where + each machine carries raw timestamps plus the computed `drifted`. + + Example: + + * `fp fleet list` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "fleet", _KEY_MODE_REASON) + cctx = require_auth(state) + machines = api.list_machines(cctx) + if output.is_json(): + # Only `--json` emits the deployments, and only `--json` pays for them. + # The table is built entirely from the machine records; fetching them + # for a human render was a second request whose result was discarded. + output.emit_json({ + "machines": [m.to_dict() for m in machines], + "deployments": [d.to_dict() for d in api.list_deployments(cctx)], + }) + return + output.render_fleet(machines) + + +def fleet_show( + ctx: typer.Context, + machine_id: str = typer.Argument(..., help="Machine id."), +) -> None: + """Show exactly what one machine is told to enforce. + + The set shown is the set that exists — read this before a `--set`, because + that flag replaces all of it. + + Also reports whether the machine has actually COLLECTED that deployment. A + machine can be told to run a policy and not yet have it; the policy list + alone cannot tell you which, and that is usually the question. + + Needs `policies:read`. With `--json`: `{machine, deployment}` — the machine + record (including `appliedDeployment`, `drifted`, `lastSeen` and both label + fields, with raw timestamps) and the deployment, or `deployment: null` when + nothing is deployed. + + Example: + + * `fp fleet show ci-runner-01` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "fleet show", _KEY_MODE_REASON) + cctx = require_auth(state) + # Two reads on purpose. The deployment says what the machine was TOLD to + # run; only the machine record says whether it has collected it. Showing the + # first without the second is how this view came to imply a policy was in + # force when the host had never picked it up. + machines = api.list_machines(cctx) + machine = next((m for m in machines if m.machine_id == machine_id), None) + if machine is None: + raise NotFoundError(f"no machine {machine_id!r} has checked in") + dep = api.get_deployment(cctx, machine_id) + + if output.is_json(): + output.emit_json({ + "machine": machine.to_dict(), + "deployment": dep.to_dict() if dep else None, + }) + return + output.render_machine_policies(machine_id, dep, machine) + + +def fleet_deploy( + ctx: typer.Context, + machine_id: str = typer.Argument(..., help="Machine id."), + add: Optional[List[str]] = typer.Option( + None, "--add", + help="Add or update a policy: `id`, `id@version`, `id:effect` or `id@version:effect`.", + ), + remove: Optional[List[str]] = typer.Option(None, "--remove", help="Remove a policy by id."), + replace: Optional[List[str]] = typer.Option( + None, "--set", + help="REPLACE the whole set with exactly these. Cannot be combined with --add/--remove.", + ), + create: bool = typer.Option( + False, "--create", + help="Allow deploying to a machine id that has not checked in yet (pre-staging).", + ), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Change what a machine enforces, showing the full resulting set first. + + `--add`/`--remove` read the machine's current set and apply a delta, so + nothing you did not mention is disturbed. A bare `--add` on a policy the + machine already runs keeps its pinned version rather than silently + upgrading; pass `id@version` to move it. + + `--set` replaces everything — the only way to drop policies you do not name. + + **Concurrency.** The write is a full replace with no server-side lock, so the + CLI records the generation it read and refuses if the result is not exactly + one higher: that means somebody else deployed in between and a replace does + not merge. Needs `policies:write`. With `--json`: the plan plus the resulting + deployment. + + Examples: + + * `fp fleet deploy ci-runner-01 --add no-force-push` + * `fp fleet deploy ci-runner-01 --add prod-guard@1:observe --remove old-rule` + * `fp fleet deploy ci-runner-01 --set no-force-push --set no-secret-echo` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "fleet deploy", _KEY_MODE_REASON) + cctx = require_auth(state) + + if not add and not remove and replace is None: + # Exit 2 for the same reason `--set` with `--add` is: no flag + # combination was given that this command can act on. Both are the + # caller's command line, not the server's answer. + raise click.UsageError( + "nothing to do — pass --add, --remove, or --set. " + "`fp fleet show <machine>` prints the current set." + ) + + # The server accepts a deploy to ANY id — that is how a machine can be + # pre-staged before it ever polls. It also means a typo does not fail: it + # mints a machine nobody owns, carrying policies nobody will collect, and + # the only sign is an extra row in `fleet list`. The dashboard cannot hit + # this because it deploys to a machine picked from a list; a CLI takes free + # text, so the check has to be here. + if not create: + try: + _require_machine(cctx, machine_id) + except NotFoundError: + raise NotFoundError( + f"no machine {machine_id!r} has checked in — deploying would create " + "it as a new machine id. Pass --create if that is deliberate." + ) + + current = api.get_deployment(cctx, machine_id) + published = api.list_policies(cctx) + latest = latest_versions(published) + try: + plan = plan_deploy( + machine_id, + current=current.policies if current else None, + base=current.deployment if current else None, + add=add or (), + remove=remove or (), + replace=replace, + latest=latest, + disabled=disabled_ids(published), + ) + except RefUsageError as exc: + # Exit 2, like every other bad flag value in this CLI (`--since`, + # `--expect`, `--file`). These are retype-the-command mistakes; exit 1 + # says "the server refused", which is a different thing to script on. + raise click.UsageError(str(exc)) + except RefError as exc: + raise ApiError(str(exc)) + + # A no-op exits 0 WITHOUT writing, which is desired-state semantics: a + # retrying harness re-running the same deploy should succeed, not error. + # Two consequences worth knowing rather than discovering: + # * `applied: false` in --json is the only way to tell "I changed it" from + # "it already matched" — the exit code is 0 either way, on purpose. + # * the short-circuit happens BEFORE the write, so a reader without + # `policies:write` also gets 0 here. They have not gained anything (the + # state already held and nothing was written), but the exit code alone + # is not proof of write access. + if plan.is_noop: + if output.is_json(): + output.emit_json({"plan": plan.to_dict(), "deployment": None, "applied": False}) + return + output.deployment_unchanged(machine_id) + return + + if not output.is_json(): + output.render_deploy_plan(plan) + dropped = len(plan.removed) + if not _write.confirm_destructive( + state, "replace the policy set on", machine_id, + consequence=(f"this REPLACES the whole set with the {len(plan.result)} shown above" + + (f"; {dropped} would be removed" if dropped else "")), + assume_yes=yes, + ): + if output.is_json(): + output.emit_json({"plan": plan.to_dict(), "cancelled": True, "applied": False}) + else: + output.print_cancelled() + return + + result = api.deploy_policies(cctx, machine_id, plan.result) + check_race(plan.base, result.deployment) + + if output.is_json(): + output.emit_json({ + "plan": plan.to_dict(), + "deployment": result.to_dict(), + "applied": True, + }) + return + output.deployment_applied(machine_id, result.deployment, len(result.policies)) + + +def fleet_diff( + ctx: typer.Context, + machine_id: Optional[str] = typer.Argument(None, help="Machine id. Omit for the whole fleet."), +) -> None: + """Show intent vs delivery — what a machine is told to run vs what it last pulled. + + The gap is the interesting part: a machine that has not collected its latest + deployment is not enforcing what the dashboard says it is, and nothing else + surfaces that as a single number. Needs `policies:read`. With `--json`: + `{machines:[{machineId, intended, delivered, drifted}]}` — `drifted` is the + field the CLI computes, so a harness need not derive it. + + Example: + + * `fp fleet diff` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "fleet diff", _KEY_MODE_REASON) + cctx = require_auth(state) + machines = api.list_machines(cctx) + # Every other machine-scoped command refuses an id nobody has reported + # under; this one filtered to nothing and exited 0 saying "no machines have + # checked in yet" — false, and indistinguishable from a healthy fleet. The + # list is already in hand, so the check costs no extra request. + if machine_id and machine_id not in {m.machine_id for m in machines}: + raise NotFoundError(f"no machine {machine_id!r} has checked in") + rows = [] + for m in sorted(machines, key=lambda x: x.machine_id): + if machine_id and m.machine_id != machine_id: + continue + rows.append({ + "machineId": m.machine_id, + "intended": m.deployment, + "delivered": m.applied_deployment, + "drifted": m.drifted, + }) + if output.is_json(): + output.emit_json({"machines": rows}) + return + output.render_fleet_diff(rows) + + +def fleet_history( + ctx: typer.Context, + machine_id: str = typer.Argument(..., help="Machine id."), +) -> None: + """List a machine's deployment generations, newest first. + + A reissue — the server rewriting a deployment because a policy was disabled + — appears as an ordinary entry. Needs `policies:read`. With `--json`: + `{machineId, history:[{deployment, policies, updatedAt}]}`. + + Example: + + * `fp fleet history ci-runner-01` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "fleet history", _KEY_MODE_REASON) + cctx = require_auth(state) + _require_machine(cctx, machine_id) + entries = api.deployment_history(cctx, machine_id) + if output.is_json(): + output.emit_json({"machineId": machine_id, "history": entries}) + return + output.render_deployment_history(machine_id, entries) + + +def fleet_rollback( + ctx: typer.Context, + machine_id: str = typer.Argument(..., help="Machine id."), + deployment: int = typer.Argument(..., help="The generation to reinstate."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Reinstate a past generation's policy set. + + This mints a NEW generation carrying the old set rather than rewinding the + counter, so history stays append-only. A generation containing a policy that + has since been disabled or deleted cannot be reinstated; the server says so. + + Needs `policies:write`. With `--json`: the resulting deployment, or + `{"cancelled": true}` if you decline. + + Example: + + * `fp fleet rollback ci-runner-01 3` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "fleet rollback", _KEY_MODE_REASON) + cctx = require_auth(state) + _require_machine(cctx, machine_id) + current = api.get_deployment(cctx, machine_id) + if not _write.confirm_destructive( + state, f"reinstate deployment #{deployment} on", machine_id, + consequence="this REPLACES the machine's current set with the one from that generation", + assume_yes=yes, + ): + if output.is_json(): + output.emit_json({"cancelled": True}) + else: + output.print_cancelled() + return + result = api.rollback_deployment(cctx, machine_id, deployment) + check_race(current.deployment if current else None, result.deployment) + if output.is_json(): + output.emit_json(result.to_dict()) + return + output.deployment_rolled_back(machine_id, deployment, result.deployment) + + +def fleet_rename( + ctx: typer.Context, + machine_id: str = typer.Argument(..., help="Machine id."), + label: str = typer.Argument(..., help="Human-readable label."), +) -> None: + """Give a machine a human label. The id itself never changes. + + Needs `policies:write`. With `--json`: `{machineId, labelOverride}` — the + server stores the label as an override beside the machine's self-asserted + one rather than replacing it. + + Example: + + * `fp fleet rename ci-runner-01 "CI runner (eu-west)"` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "fleet rename", _KEY_MODE_REASON) + cctx = require_auth(state) + res = api.rename_machine(cctx, machine_id, label) + if output.is_json(): + output.emit_json(res) + return + output.machine_renamed(machine_id, label) + + +def register(app: typer.Typer) -> None: + fleet_app = typer.Typer( + no_args_is_help=True, + rich_markup_mode="markdown", + context_settings={"help_option_names": ["-h", "--help"]}, + help="The fleet and what each machine enforces (list / show / deploy / diff / history / rollback / rename).", + ) + fleet_app.command("list", epilog=GLOBALS_EPILOG)(fleet_list) + fleet_app.command("show", epilog=GLOBALS_EPILOG)(fleet_show) + fleet_app.command("deploy", epilog=GLOBALS_EPILOG)(fleet_deploy) + fleet_app.command("diff", epilog=GLOBALS_EPILOG)(fleet_diff) + fleet_app.command("history", epilog=GLOBALS_EPILOG)(fleet_history) + fleet_app.command("rollback", epilog=GLOBALS_EPILOG)(fleet_rollback) + fleet_app.command("rename", epilog=GLOBALS_EPILOG)(fleet_rename) + app.add_typer(fleet_app, name="fleet") diff --git a/fp-cli/fp_cli/commands/guardrails_cmds.py b/fp-cli/fp_cli/commands/guardrails_cmds.py new file mode 100644 index 000000000..78093b0fc --- /dev/null +++ b/fp-cli/fp_cli/commands/guardrails_cmds.py @@ -0,0 +1,138 @@ +"""Guardrails: what enforcement actually did — `summary` and `timeline`. + +The counterpart to `fp fleet`. That command says what the control plane +INTENDED; this says what happened — whether the fleet is really covered, what +got blocked, and which policies earn their place. + +The two halves come from different stores and that is worth knowing when a +number looks wrong: coverage is Postgres (the deployments), while the decision +counts are ClickHouse (hook telemetry the machines reported). A machine can be +deployed-to and silent, or reporting and undeployed, and only the first half +moves when you run `fp fleet deploy`. +""" +from __future__ import annotations + +from typing import Optional + +import typer + +from .. import client as api +from .. import output +from .._context import GLOBALS_EPILOG, AppState, deny_in_key_mode, require_auth +from ..errors import NotFoundError + +_KEY_MODE_REASON = ( + "guardrails reads an operator surface that is not exposed on the versioned " + "API that an API key authenticates against" +) + + +def _require_machine(cctx, machine_id: Optional[str]) -> None: + """Refuse a `--machine` id nobody has ever reported under. + + Both views answer "what happened here", and both answer an unknown machine + with an empty window — which reads as "this machine was quiet", not as "you + typed the id wrong". `fp fleet` refuses the same mistake everywhere else; + the check costs one request, and only when the flag is actually used. + """ + if machine_id is None: + return + if machine_id not in {m.machine_id for m in api.list_machines(cctx)}: + raise NotFoundError(f"no machine {machine_id!r} has checked in") + + +def _hours(since: str) -> int: + """`24h`/`7d`/`60m` → hours. The CLI's `--since` vocabulary, one window only.""" + table = {"15m": 1, "1h": 1, "6h": 6, "24h": 24, "7d": 168} + if since in table: + return table[since] + # A usage error, not a runtime one: exit 2 like every other bad flag value, + # rather than the exit 1 an uncaught ValueError would produce. + raise typer.BadParameter( + f"invalid --since value {since!r}; choose one of {', '.join(table)}" + ) + + +def guardrails_summary( + ctx: typer.Context, + since: str = typer.Option("24h", "--since", help="Window: 15m, 1h, 6h, 24h, 7d."), + machine: Optional[str] = typer.Option(None, "--machine", help="Scope to one machine id."), +) -> None: + """Coverage, blocks, and the per-policy table for a window. + + Shows evaluated/blocked totals, how many machines are enforcing versus + merely reporting, a 24-bin sparkline of denies, and each policy's + fired/blocked/instructed/p95. + + A `(no policy)` row is normal, not a gap: most evaluations are allows that + no policy objected to, and the row keeps the denominator on screen — "14 + blocked" means little without the 933 it came from. + + Needs `policies:read`. With `--json`: the server summary plus the timeline. + + Examples: + + * `fp guardrails` + * `fp guardrails --since 7d --machine ci-runner-01` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "guardrails", _KEY_MODE_REASON) + cctx = require_auth(state) + hours = _hours(since) + _require_machine(cctx, machine) + summary = api.enforcement_summary(cctx, hours=hours, machine_id=machine) + timeline = api.decision_timeline(cctx, hours=hours, machine_id=machine) + if output.is_json(): + output.emit_json({"summary": summary, "timeline": timeline}) + return + output.render_guardrails(summary, timeline) + + +def guardrails_timeline( + ctx: typer.Context, + since: str = typer.Option("24h", "--since", help="Window: 15m, 1h, 6h, 24h, 7d."), + machine: Optional[str] = typer.Option(None, "--machine", help="Scope to one machine id."), +) -> None: + """When enforcement bit, and how hard — one row per time bucket. + + Shows `time · activity · total · denied · instructed`. The bar is scaled to + the busiest bucket in the window, with the blocked share drawn in red inside + it, so a quiet hour and a heavily-blocked hour are distinguishable at a + glance rather than by reading numbers. + + Times are UTC, and the label follows the bucket size the server chose — a + clock for hourly buckets, a date for daily ones. + + Needs `policies:read`. With `--json`: the server's timeline verbatim. + + Example: + + * `fp guardrails timeline --since 24h` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "guardrails timeline", _KEY_MODE_REASON) + cctx = require_auth(state) + hours = _hours(since) + _require_machine(cctx, machine) + data = api.decision_timeline(cctx, hours=hours, machine_id=machine) + if output.is_json(): + output.emit_json(data) + return + output.render_decision_timeline(data) + + +def register(app: typer.Typer) -> None: + # A pure container, like every other group in this CLI: bare `fp guardrails` + # prints its help rather than running something. It used to run the summary + # from a callback, which made it the only group that did — and put `--since` + # in two places, where the group-level copy silently shadowed nothing and + # taught the wrong shape. + guardrails_app = typer.Typer( + no_args_is_help=True, + rich_markup_mode="markdown", + context_settings={"help_option_names": ["-h", "--help"]}, + help="What enforcement actually did (summary / timeline).", + ) + guardrails_app.command("summary", epilog=GLOBALS_EPILOG)(guardrails_summary) + guardrails_app.command("timeline", epilog=GLOBALS_EPILOG)(guardrails_timeline) + app.add_typer(guardrails_app, name="guardrails") diff --git a/fp-cli/fp_cli/commands/incidents_cmds.py b/fp-cli/fp_cli/commands/incidents_cmds.py new file mode 100644 index 000000000..a5fa7c6dc --- /dev/null +++ b/fp-cli/fp_cli/commands/incidents_cmds.py @@ -0,0 +1,472 @@ +"""Incident triage: incidents list/count/show/ack/assign/resolve/comment*/subscribe*/open. + +Incidents live under /api/issues but the triage workflow is distinct, so it +gets its own top-level group. The id IS the handle (incidents have no human name), so the +action commands take it directly; the boxed views show a short id + the alert label. +""" + +from __future__ import annotations + +import re +from typing import List, Optional + +import typer + +from .. import client as api +from .. import output +from .._context import GLOBALS_EPILOG, AppState, require_auth, validate_limit +from ..errors import ApiError, ForbiddenError, NotFoundError +from . import _write + +_SEVERITIES = ("info", "warning", "critical") +_STATES = ("firing", "acknowledged", "resolved") +_UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + + +def _validate_states(value: Optional[str]) -> None: + """Reject an unknown ``--state`` value up front (exit 2) rather than letting the server + silently drop it and return a confusing set. Accepts a CSV of firing/acknowledged/resolved.""" + if value is None: + return + for s in value.split(","): + s = s.strip() + if s and s not in _STATES: + raise typer.BadParameter( + f"'{s}' is not a valid state. Choose from: {', '.join(_STATES)} (CSV).", + param_hint="--state", + ) + + +def _fail(state: AppState, exc: Exception, *, incident_id: str = "") -> None: + """Re-raise as a typed error for the central chokepoint to render (JSON envelope under + ``--json`` on stdout, red box otherwise). A 404 — or a **malformed (non-UUID) id**, which the + server's path extractor answers with a 400 rather than a 404 — becomes the friendlier + ``no issue <id>`` (exit 6); every other ApiError/ForbiddenError keeps the server's message + and exit code.""" + # EXACTLY 400, not a range. This read `>= 500` on the belief that the server answers a + # malformed id with a 500, and it does not: axum's path extractor rejects it at 400 with a + # plain-text body, which the dashboard turns into the generic "upstream returned non-JSON + # response". So the remap never fired — `fp issues show not-a-uuid` exited 1 carrying that + # internal phrase while `fp audits show not-a-uuid` exited 6 with a usable message, and + # anything branching on exit 6 to mean not-found silently took the wrong arm. + # + # `>= 400` is the obvious fix and it is WRONG, which is why this is spelled out. Issue ids + # are not required to be UUIDs — `fp issues assign i1 --assignee ...` is a documented call — + # so a non-UUID id reaches real handlers and collects real 4xx answers. A 422 "a@x.com is + # not an operator" would then be rewritten as "no issue i1", replacing the one sentence that + # explains the failure with a claim that is false. Only 400 means "the router refused to + # parse this id"; every other 4xx got past the extractor and has something to say. + status = getattr(exc, "status", None) or 0 + malformed = bool(incident_id) and not _UUID_RE.match(incident_id) and status == 400 + if incident_id and (malformed or isinstance(exc, NotFoundError)): + raise NotFoundError( + f"no issue {incident_id}", hint="run `fp issues list` to see open issues" + ) + raise exc + + +def incidents_list( + ctx: typer.Context, + state_filter: Optional[str] = typer.Option(None, "--state", help="Filter by state(s): firing, acknowledged, resolved (CSV)."), + alert_id: Optional[str] = typer.Option(None, "--alert-id", help="Only incidents for this alert."), + limit: int = typer.Option(50, "--limit", "-n", help="Max incidents to return."), + show_id: bool = typer.Option(False, "--show-id", help="Show the full incident id instead of the short form (always full in --json)."), +) -> None: + """List incidents in a boxed table (newest-opened first). + + Shows `id · title · source · severity · state · opened · assignees` — the id is the handle the + action commands take (short by default, full with `--show-id`); `title` is the issue's own + identifying line (every issue has one, unlike `alert_name`); `source` is where it came from + (`manual`/`alert`/`audit`) and trails the alert name when there is one; severity is + colour-coded, state as `● firing`/`● acknowledged`/`○ resolved`, `opened` the compact age. + A footer breaks down the state distribution. Needs `issues:read`. With `--json`: + `{"issues": [{id, title, source, source_finding_id, alert_name, alert_severity, state, + opened_at, assignees, ...}]}`. + + Example: + + * `fp issues list --state firing` + """ + state: AppState = ctx.obj + _validate_states(state_filter) + validate_limit(limit) + cctx = require_auth(state) + incidents = api.list_incidents(cctx, state=state_filter, alert_id=alert_id, limit=limit) + if state.json: + output.emit_json({"issues": incidents}) + return + output.render_incidents(incidents, show_id=show_id) + output.incidents_footer(incidents) + + +def incidents_count( + ctx: typer.Context, + state_filter: Optional[str] = typer.Option(None, "--state", help="Filter by state(s) (CSV)."), +) -> None: + """Count incidents (optionally by state) as a compact stat card. + + With no `--state` the server counts the open ones (firing + acknowledged). Needs + `issues:read`. With `--json`: `{"count": N}`. + + Example: + + * `fp issues count --state firing` + """ + state: AppState = ctx.obj + _validate_states(state_filter) + cctx = require_auth(state) + count = api.count_incidents(cctx, state=state_filter) + if state.json: + output.emit_json({"count": count}) + else: + output.render_incident_count(count, state=state_filter) + + +def incidents_show( + ctx: typer.Context, + incident_id: str = typer.Argument(..., help="Incident id."), +) -> None: + """Show one incident in full — a stack of cards: identity, comments, subscribers, activity. + + The identity card is headed by the issue's own title and shows severity · state · source · + opened, who acknowledged/is assigned, and the breach; empty sections are omitted. Not-found → + `✗ no incident <short id>`, exit 6. Needs `issues:read`. With `--json`: the full `Incident` + (including `title`, `source`, `source_finding_id`, and untouched + comments/subscribers/activity). + + Example: + + * `fp issues show <id>` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + try: + incident = api.get_incident(cctx, incident_id) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc, incident_id=incident_id) + if state.json: + output.emit_json(incident) + return + output.render_incident_show(incident) + + +def incidents_ack( + ctx: typer.Context, + incident_id: str = typer.Argument(..., help="Incident id."), +) -> None: + """Acknowledge an incident (no confirm — it isn't destructive). + + Needs `issues:read` (ack rides on read). With `--json`: `{"acknowledged": true, "id": "<id>"}`. + + Example: + + * `fp issues ack <id>` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + try: + api.ack_incident(cctx, incident_id) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc, incident_id=incident_id) + _write.record_action("incident_acked", resource="incident", success=True) + if state.json: + output.emit_json({"acknowledged": True, "id": incident_id}) + else: + output.incident_acked(incident_id) + + +def incidents_assign( + ctx: typer.Context, + incident_id: str = typer.Argument(..., help="Incident id."), + assignee: Optional[List[str]] = typer.Option(None, "--assignee", help="Operator email to assign (repeatable; omit to clear all)."), +) -> None: + """Set an incident's assignees (replaces the list; omit `--assignee` to clear). + + Needs `issues:create`. The server rejects the whole call if any email is not an operator — + that's surfaced as a clean `✗ <message>`. With `--json`: `{"assignees": [...], "id": "<id>"}`. + + Example: + + * `fp issues assign <id> --assignee a@example.com --assignee b@example.com` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + assignees = assignee or [] + try: + api.assign_incident(cctx, incident_id, assignees) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc, incident_id=incident_id) + _write.record_action("incident_assigned", resource="incident", success=True) + if state.json: + output.emit_json({"assignees": assignees, "id": incident_id}) + else: + output.incident_assigned(incident_id, assignees) + + +def incidents_resolve( + ctx: typer.Context, + incident_id: str = typer.Argument(..., help="Incident id."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Resolve (close) an incident, after a calm confirm. + + Needs `issues:close`. With `--json`: `{"resolved": true, "id": "<id>"}` (or `{cancelled: true}` + on a declined prompt). + + Example: + + * `fp issues resolve <id> --yes` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + if _write.should_prompt(state, yes): + alert_name = None + try: + alert_name = api.get_incident(cctx, incident_id).alert_name + except NotFoundError as exc: + _fail(state, exc, incident_id=incident_id) + except (ApiError, ForbiddenError): + alert_name = None + if not output.confirm_incident_resolve(incident_id, alert_name): + if state.json: + output.emit_json({"cancelled": True}) + else: + output.cancelled_plain("nothing changed") + return + try: + api.resolve_incident(cctx, incident_id) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc, incident_id=incident_id) + _write.record_action("incident_resolved", resource="incident", success=True) + if state.json: + output.emit_json({"resolved": True, "id": incident_id}) + else: + output.incident_resolved(incident_id) + + +def incidents_comment_list( + ctx: typer.Context, + incident_id: str = typer.Argument(..., help="Incident id."), +) -> None: + """List an incident's comments in a boxed table. + + Shows `author · when · body` (the body wraps; a deleted comment shows a dim `(deleted)`). + Needs `issues:read`. With `--json`: `{"comments": [{id, author_email, body, created_at, ...}]}`. + """ + state: AppState = ctx.obj + cctx = require_auth(state) + try: + comments = api.list_incident_comments(cctx, incident_id) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc, incident_id=incident_id) + if state.json: + output.emit_json({"comments": comments}) + return + output.render_incident_comments(comments) + + +def incidents_comment_add( + ctx: typer.Context, + incident_id: str = typer.Argument(..., help="Incident id."), + body: Optional[str] = typer.Option(None, "--body", help="Comment text (or use --file/-)."), + file: Optional[str] = typer.Option(None, "--file", help="Read the comment body from a file, or `-` for stdin."), +) -> None: + """Add a comment to an incident (rendered as a green "comment added" card). + + Needs `issues:read` (commenting rides on read). Provide exactly one of `--body` or `--file`/stdin. With `--json`: the + created comment. + + Example: + + * `fp issues comment-add <id> --body "looking into it"` + """ + state: AppState = ctx.obj + if (body is None) == (file is None): + raise typer.BadParameter("Provide exactly one of --body or --file.") + text = body if body is not None else _write.read_text_arg(file) + cctx = require_auth(state) + try: + comment = api.create_incident_comment(cctx, incident_id, text) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc, incident_id=incident_id) + _write.record_action("incident_comment_added", resource="incident", success=True) + if state.json: + output.emit_json(comment) + else: + output.render_incident_comment_added(comment) + + +def incidents_comment_delete( + ctx: typer.Context, + incident_id: str = typer.Argument(..., help="Incident id."), + comment_id: str = typer.Argument(..., help="Comment id."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Delete an incident comment, after an amber preview + confirm. + + Resolves the comment first (so an unknown comment id → `✗ no comment …`, exit 6), previews it, + then confirms. Needs `issues:read` to delete your own comment, `issues:close` to moderate others'. With `--json`: `{"deleted": true, "id": "<comment_id>"}` + (or `{cancelled: true}` on a declined prompt). + """ + state: AppState = ctx.obj + cctx = require_auth(state) + try: + comments = api.list_incident_comments(cctx, incident_id) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc, incident_id=incident_id) + match = next((c for c in comments if c.id == comment_id), None) + if match is None: + raise NotFoundError(f"no comment {comment_id}") + if _write.should_prompt(state, yes): + output.render_incident_comment_delete_preview(match) + if not output.confirm_incident_comment_delete(): + if state.json: + output.emit_json({"cancelled": True}) + else: + output.cancelled_plain("nothing deleted") + return + try: + api.delete_incident_comment(cctx, incident_id, comment_id) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc, incident_id=incident_id) + _write.record_action("incident_comment_deleted", resource="incident", success=True, destructive=True) + if state.json: + output.emit_json({"deleted": True, "id": comment_id}) + else: + output.incident_comment_deleted() + + +def incidents_subscribers( + ctx: typer.Context, + incident_id: str = typer.Argument(..., help="Incident id."), +) -> None: + """List who is subscribed to an incident in a boxed table. + + Shows `email · source · subscribed`. Needs `issues:read`. With `--json`: + `{"subscribers": [{email, source, subscribed_at, ...}]}`. + """ + state: AppState = ctx.obj + cctx = require_auth(state) + try: + subs = api.list_incident_subscribers(cctx, incident_id) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc, incident_id=incident_id) + if state.json: + output.emit_json({"subscribers": subs}) + return + output.render_incident_subscribers(subs) + + +def incidents_subscribe( + ctx: typer.Context, + incident_id: str = typer.Argument(..., help="Incident id."), + email: Optional[str] = typer.Option(None, "--email", help="Email to subscribe (default: you)."), +) -> None: + """Subscribe to an incident's notifications. + + With `--json`: `{"subscribed": true, "id": "<id>"}`. + """ + state: AppState = ctx.obj + cctx = require_auth(state) + try: + api.subscribe_incident(cctx, incident_id, email) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc, incident_id=incident_id) + _write.record_action("incident_subscribed", resource="incident", success=True) + if state.json: + output.emit_json({"subscribed": True, "id": incident_id}) + else: + output.incident_subscribed(incident_id, email) + + +def incidents_unsubscribe( + ctx: typer.Context, + incident_id: str = typer.Argument(..., help="Incident id."), + email: Optional[str] = typer.Option(None, "--email", help="Email to unsubscribe (default: you)."), +) -> None: + """Unsubscribe from an incident's notifications. + + With `--json`: `{"unsubscribed": true, "id": "<id>"}`. + """ + state: AppState = ctx.obj + cctx = require_auth(state) + try: + api.unsubscribe_incident(cctx, incident_id, email) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc, incident_id=incident_id) + _write.record_action("incident_unsubscribed", resource="incident", success=True) + if state.json: + output.emit_json({"unsubscribed": True, "id": incident_id}) + else: + output.incident_unsubscribed(incident_id, email) + + +def incidents_open( + ctx: typer.Context, + summary: str = typer.Option(..., "--summary", help="Short description of the incident."), + title: Optional[str] = typer.Option(None, "--title", help="Short title. Required unless --alert-id is given."), + alert_id: Optional[str] = typer.Option(None, "--alert-id", help="Link to an alert (inherits its severity)."), + severity: Optional[str] = typer.Option(None, "--severity", help=f"Severity for a standalone incident: {', '.join(_SEVERITIES)}."), +) -> None: + """Open a manual incident (standalone, or linked to an alert) — rendered as a green card. + + Needs `issues:create`. `--title` is required for a standalone incident (there's no parent + alert whose name it could borrow) and optional with `--alert-id`, where it defaults to the + alert's name. A missing `--title` or an invalid `--severity` → exit 2. With `--json`: + `{id, newly_opened, state}`. + + Examples: + + * `fp issues open --title "checkout 500s" --summary "manual page" --severity critical` + * `fp issues open --alert-id <id> --summary "paging on this again"` + """ + state: AppState = ctx.obj + if severity and severity not in _SEVERITIES: + raise typer.BadParameter(f"severity must be one of: {', '.join(_SEVERITIES)}.") + if not alert_id and not (title or "").strip(): + raise typer.BadParameter("--title is required for a standalone issue (or pass --alert-id).") + cctx = require_auth(state) + try: + result = api.open_incident(cctx, summary=summary, alert_id=alert_id, severity=severity, title=title) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc, incident_id=alert_id or "") + _write.record_action("incident_opened", resource="incident", success=True) + if state.json: + output.emit_json(result) + return + # The open response is minimal ({id, newly_opened, state}); re-fetch the canonical incident + # for a richer card (its real severity — esp. a linked incident inheriting the alert's). + inc = None + try: + inc = api.get_incident(cctx, str(result.get("id", ""))) + except Exception: + inc = None + sev = (inc.alert_severity if inc else None) or severity or "" + st = (inc.state if inc else None) or str(result.get("state", "") or "") + # Prefer the server's stored title — on the linked path it may have been + # defaulted to the alert's name rather than anything we sent. + hero = (inc.title if inc else None) or str(result.get("title", "") or "") or (title or "") + output.render_incident_opened(summary=summary, severity=sev, state=st, title=hero) + + +def register(app: typer.Typer) -> None: + inc = typer.Typer( + no_args_is_help=True, + rich_markup_mode="markdown", + context_settings={"help_option_names": ["-h", "--help"]}, + help="Triage issues (list / count / show / ack / assign / resolve / comment-* / subscribe* / open).", + ) + inc.command("list", epilog=GLOBALS_EPILOG)(incidents_list) + inc.command("count", epilog=GLOBALS_EPILOG)(incidents_count) + inc.command("show", epilog=GLOBALS_EPILOG)(incidents_show) + inc.command("ack", epilog=GLOBALS_EPILOG)(incidents_ack) + inc.command("assign", epilog=GLOBALS_EPILOG)(incidents_assign) + inc.command("resolve", epilog=GLOBALS_EPILOG)(incidents_resolve) + inc.command("comment-list", epilog=GLOBALS_EPILOG)(incidents_comment_list) + inc.command("comment-add", epilog=GLOBALS_EPILOG)(incidents_comment_add) + inc.command("comment-delete", epilog=GLOBALS_EPILOG)(incidents_comment_delete) + inc.command("subscribers", epilog=GLOBALS_EPILOG)(incidents_subscribers) + inc.command("subscribe", epilog=GLOBALS_EPILOG)(incidents_subscribe) + inc.command("unsubscribe", epilog=GLOBALS_EPILOG)(incidents_unsubscribe) + inc.command("open", epilog=GLOBALS_EPILOG)(incidents_open) + app.add_typer(inc, name="issues") diff --git a/fp-cli/fp_cli/commands/keys_cmds.py b/fp-cli/fp_cli/commands/keys_cmds.py new file mode 100644 index 000000000..e5a16e44b --- /dev/null +++ b/fp-cli/fp_cli/commands/keys_cmds.py @@ -0,0 +1,407 @@ +"""API key provisioning: keys list / show / create / update / disable / regenerate. + +Keys are referenced by their **name** (unique within the org) across ``show`` / ``update`` / +``disable`` / ``regenerate``. The group mirrors the ``users`` visual + permission language: a +boxed list, a ``show`` identity card + grouped permissions panel, and the SAME +``--permission-set`` / ``--add`` / ``--remove`` flags as ``users create`` / ``users update``. + +A KEY stores a **flat permission list** (no persisted role/set), so ``--permission-set`` is +expanded **client-side** to seed the grants (matching the dashboard's key SetPicker); the +human-only permissions (``keys:update`` / ``orgs:admin``) are stripped from a seed and rejected +from an explicit ``--add`` (the server's ``key_assignable`` would 422 them). + +The CLI generates the secret for ``create`` (the server never echoes it back) and prints it +exactly once. ``regenerate`` rotates the secret and prints the new one once. Secrets go to +**stdout** (capturable); the "shown once" warning goes to stderr. +""" + +from __future__ import annotations + +import secrets +import sys +from typing import List, Optional + +import typer + +from .. import _click_compat as click # the Click Typer is running; see _click_compat +from .. import client as api +from .. import output, permissions +from .._context import ( + GLOBALS_EPILOG, + AppState, + deny_in_key_mode, + require_auth, + resolve_fields, +) +from ..errors import ForbiddenError, NotFoundError +from ..models import ApiKey +from ..permissions import PermissionTokenError +from ..permissions import parse_permission_tokens as _parse_permissions +from . import _write + +# ``PermissionTokenError`` / ``_parse_permissions`` live in ``permissions.py`` (shared with +# ``users``); re-exported here under their historical names so the keys flow + its tests keep +# importing them from this module. +__all__ = ["PermissionTokenError", "_parse_permissions", "register"] + + +def _resolve_key_or_exit(state: AppState, keys, name: str) -> ApiKey: + """Resolve a unique key NAME (or id) to its key, raising a typed error the central + chokepoint renders (JSON envelope under ``--json``, red box otherwise): none → exit 6, + several → exit 2.""" + return _write.resolve_one(keys, name, kind="key", list_cmd="keys list") + + +def _parse_key_tokens_or_exit(state: AppState, tokens) -> List[str]: + """Expand the compact ``slug:act1.act2`` ``--add`` / ``--remove`` tokens for a KEY (empty when + none given). A malformed/unknown token — or a human-only permission (``keys:update`` / + ``orgs:admin``) — → a clean usage error (exit 2), before any create/update.""" + if not tokens: + return [] + try: + return permissions.parse_key_permission_tokens(tokens) + except PermissionTokenError as exc: + raise click.UsageError(str(exc)) + + +def _expand_key_set_or_exit(state: AppState, cctx, set_name: str) -> List[str]: + """Expand a ``--permission-set`` NAME → its **key-assignable** permissions, to seed a key's + flat grants (keys have no persisted set). Resolves against the ORG's sets (fetched, so custom + sets work like the dashboard's SetPicker), falling back to the built-in presets if that read + is unavailable. Human-only perms are stripped. Unknown set → clean error + exit 2.""" + try: + sets = api.list_permission_sets(cctx) + except (ForbiddenError, NotFoundError): + sets = {} + if set_name in sets: + return permissions.key_assignable_only(sets[set_name]) + if set_name in permissions.PRESETS: + return permissions.key_assignable_only(permissions.PRESETS[set_name]) + available = sorted(set(sets) | (set(permissions.PRESETS) - {"clear"})) + raise click.UsageError( + f'unknown permission set "{set_name}". available: {", ".join(available)}' + ) + + +def keys_list( + ctx: typer.Context, + show_id: bool = typer.Option(False, "--show-id", help="Prepend a short key-id column (the full id is always in --json)."), + fields: Optional[str] = typer.Option(None, "--fields", help="Comma-separated subset of fields. e.g. `id,name,permissions`."), +) -> None: + """List the org's API keys (metadata only — secrets are never returned), active keys first. + + Shows `created · name · permissions · status` in a boxed table — **active keys sort to the + top** (then revoked), each group newest-first. The raw key id is hidden by default (use + `--show-id` for a short id, or `--json` for the full id). Needs `keys:read`. With `--json`: + `{"keys": [{id, name, permissions, created_at, revoked_at}]}`. + + Example: + + * `fp keys list` + * `fp --json keys list` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + keys = api.list_keys(cctx) + cols = resolve_fields(fields, ApiKey) + if state.json: + payload = output.project_dicts(keys, cols) if cols else keys + output.emit_json({"keys": payload}) + return + if cols: + # `--fields` asks for specific raw columns → the generic table (no bespoke styling). + output.print_table(list(cols), output.project_rows(keys, cols), title=f"API keys ({len(keys)})") + return + output.render_keys(keys, show_id=show_id) + output.keys_footer(keys) + + +def keys_show( + ctx: typer.Context, + name: str = typer.Argument(..., help="Key name (unique within the org)."), +) -> None: + """Show one key's identity and full permissions — like `users show`, for an API key. + + Renders an identity card (name, created date, permission count, status) then the shared + grouped permissions panel with **all** the key's grants. Referenced by **name**. Needs + `keys:read`. With `--json`: the full key object `{id, name, permissions, created_at, revoked_at}`. + + Example: + + * `fp keys show ci-bot` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + key = _resolve_key_or_exit(state, api.list_keys(cctx), name) + if state.json: + output.emit_json(key) + return + output.render_key_show(key) + + +def keys_create( + ctx: typer.Context, + name: str = typer.Argument(..., help="Human-readable key name (unique within the org)."), + permission_set: Optional[str] = typer.Option(None, "--permission-set", help="Role to seed the key from — a permission set: `read-only`, `standard`, `admin`, or a custom set your org defines in the dashboard. The set is expanded into the key's grants (human-only perms are dropped). Omit for no base role."), + add: Optional[List[str]] = typer.Option(None, "--add", help="Grant extra permissions on top of the set, as `slug:action.action` tokens (dotted actions expand: `events:read.add` → `events:read`, `events:add`). Several via comma, repeated flag, or a quoted group: `--add events:read,keys:read` · `--add a --add b` · `--add \"a b\"`."), + remove: Optional[List[str]] = typer.Option(None, "--remove", help="Drop permissions from the set, same `slug:action.action` token format as --add (comma / repeated / quoted)."), +) -> None: + """Create an API key and reveal its secret **once**. + + Grant permissions exactly like `users create`: start from a role with `--permission-set` + (a built-in `read-only` / `standard` / `admin`, or a custom org set — expanded into the key's + grants), then fine-tune with `--add` / `--remove`. The effective grants are + `(set ∪ added) − removed`. `--add` / `--remove` take the compact `slug:action.action` token + format (dotted actions expand). Human-only permissions (`keys:update`) can't be granted to a + key. + + The secret is generated locally; the server stores only a hash. Needs `keys:create`. With + `--json`: `{id, name, permissions, created_at, key}` — `key` is the only place the secret + appears, and `permissions` is the expanded flat list. + + Examples: + + * `fp keys create ci-bot --permission-set read-only` — a read-only key + * `fp keys create deployer --add events:read.add,keys:read` — just the listed grants + * `fp keys create ops --permission-set standard --add keys:create --remove agent:use` — a role, tuned + * `fp keys create ci-bot --permission-set read-only | pbcopy` — pipe captures just the secret + """ + state: AppState = ctx.obj + if not name.strip(): + raise typer.BadParameter("key name must not be empty.", param_hint="NAME") + parsed_add = _parse_key_tokens_or_exit(state, add) + parsed_remove = _parse_key_tokens_or_exit(state, remove) + both = sorted(set(parsed_add) & set(parsed_remove)) + if both: + raise typer.BadParameter(f"{', '.join(both)} given to both --add and --remove.") + cctx = require_auth(state) + if any(k.name == name for k in api.list_keys(cctx)): # names are unique + raise click.UsageError(f'a key named "{name}" already exists') + base = _expand_key_set_or_exit(state, cctx, permission_set) if permission_set else [] + flat = sorted((set(base) | set(parsed_add)) - set(parsed_remove)) + secret = secrets.token_hex(32) # 64 hex chars, mirrors the dashboard's generateToken() + result = api.create_key(cctx, name=name, key=secret, permissions=flat) + _write.record_action("api_key_created", resource="key", success=True, permission_count=len(flat)) + perms = result.permissions or flat + if state.json: + output.emit_json({"id": result.id, "name": result.name, "permissions": perms, + "created_at": result.created_at, "key": secret}) + return + if not sys.stdout.isatty(): + print(secret) # piped/redirected → just the secret (capturable) + return + output.render_key_created(result) # green identity card + output.render_created_secret_box(secret) # the secret, shown once + output.permissions_box(perms) # the grouped grants + + +def keys_update( + ctx: typer.Context, + name: str = typer.Argument(..., help="Key name to update (unique within the org)."), + permission_set: Optional[str] = typer.Option(None, "--permission-set", help="Reseed the key from a permission set: `read-only`, `standard`, `admin`, or a custom org set. REPLACES the key's grants with the set (then applies any --add/--remove). Human-only perms are dropped."), + add: Optional[List[str]] = typer.Option(None, "--add", help="Grant permissions, same `slug:action.action` token format as `keys create` (dotted actions expand; comma / repeated flag / quoted compose). Incremental — merged into the key's CURRENT grants, unless --permission-set is also given."), + remove: Optional[List[str]] = typer.Option(None, "--remove", help="Revoke permissions, same `slug:action.action` token format as --add. Incremental — applied to the key's CURRENT grants, unless --permission-set is also given."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Change an API key's permissions and show the diff — like `users update`, by key name. + + Two ways to use it (combine if you like): + + * **Reseed from a role** — `--permission-set <set>` replaces the key's grants with that set + (a built-in `read-only` / `standard` / `admin`, or a custom org set); add `--add`/`--remove` + to tune it. + * **Tweak incrementally** — `--add` / `--remove` **alone** adjust the key's *current* grants. + Both take the compact `slug:action.action` token format (dotted actions expand). Human-only + permissions (`keys:update`) can't be granted to a key. + + The CLI reads the current grants, computes the result `(set ∪ added) − removed`, and shows a + git-style diff — added green, removed struck-through, unchanged dim. It confirms first (default + no; `--yes` skips); a no-op exits without calling the server. The key keeps working — only its + permissions change. Needs `keys:update`. With `--json`: + `{id, name, permissions, created_at, revoked_at, added, removed}`. + + Examples: + + * `fp keys update ci-bot --add keys:read` — grant on top of current grants + * `fp keys update ci-bot --remove events:add,agent:use` — revoke a couple of grants + * `fp keys update ci-bot --permission-set standard --yes` — reseed from the standard role + """ + state: AppState = ctx.obj + # The only `keys` subcommand an API key can never run: it needs `keys:update`, and + # `Permission::key_assignable` forbids that grant on ANY key (a bearer key may + # create keys, never edit one). So this is unreachable by construction, not just + # unlikely — say so here instead of letting the server's 403 imply "ask for the + # permission", which nobody can grant. + deny_in_key_mode( + state, + "keys update", + "it needs the keys:update permission, which cannot be granted to any API key", + ) + if permission_set is None and not add and not remove: + raise typer.BadParameter("nothing to update — pass --permission-set, --add, and/or --remove.") + parsed_add = _parse_key_tokens_or_exit(state, add) + parsed_remove = _parse_key_tokens_or_exit(state, remove) + both = sorted(set(parsed_add) & set(parsed_remove)) + if both: + raise typer.BadParameter(f"{', '.join(both)} given to both --add and --remove.") + cctx = require_auth(state) + key = _resolve_key_or_exit(state, api.list_keys(cctx), name) + before = set(key.permissions) + add_set, remove_set = set(parsed_add), set(parsed_remove) + + if permission_set is not None: + # Reseed: the set is the base; --add/--remove are fresh tweaks on top. + base = _expand_key_set_or_exit(state, cctx, permission_set) + after = sorted((set(base) | add_set) - remove_set) + else: + # Incremental: apply the deltas to the key's CURRENT flat grants. + after = sorted((before | add_set) - remove_set) + after_set = set(after) + added, removed = sorted(after_set - before), sorted(before - after_set) + + if not added and not removed: # no-op: don't call the server, don't prompt + if state.json: + output.emit_json({"id": key.id, "name": key.name, "permissions": sorted(before), + "created_at": key.created_at, "revoked_at": key.revoked_at, + "added": [], "removed": []}) + else: + output.key_no_change() + return + + proceed = (not _write.should_prompt(state, yes)) or output.confirm_key_update( + key.name, len(added), len(removed)) + if not proceed: + if state.json: + output.emit_json({"cancelled": True}) + else: + output.print_cancelled("permissions unchanged") + return + + result = api.update_key(cctx, key.id, permissions=after) + final = set(result.permissions) + added, removed = sorted(final - before), sorted(before - final) + _write.record_action("api_key_updated", resource="key", success=True, permission_count=len(final)) + if state.json: + output.emit_json({"id": result.id, "name": result.name, "permissions": sorted(final), + "created_at": result.created_at, "revoked_at": result.revoked_at, + "added": added, "removed": removed}) + return + output.render_key_updated(result, added=added, removed=removed, union=sorted(before | final)) + + +def keys_disable( + ctx: typer.Context, + name: str = typer.Argument(..., help="Key name to disable (unique within the org)."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Disable (revoke) an API key by name. This cannot be undone. + + Resolves the unique key name, confirms (amber, default no), then revokes it — anything + using the key stops working immediately. Needs `keys:disable`. With `--json`: + `{"name", "status": "disabled"}` (or `{"cancelled": true}` on a declined prompt). + + Example: + + * `fp keys disable "ci-bot" --yes` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + key = _resolve_key_or_exit(state, api.list_keys(cctx), name) + if key.revoked_at: # already disabled — a no-op, not an error + if state.json: + output.emit_json({"name": key.name, "status": "disabled"}) + else: + output.key_already_disabled(key.name) + return + if not _write.confirm_destructive( + state, "disable the key", key.name, + consequence="this revokes the key immediately — anything using it will stop working", + assume_yes=yes, + ): + if state.json: + output.emit_json({"cancelled": True}) + else: + output.print_cancelled() + return + api.disable_key(cctx, key.id) + _write.record_action("api_key_disabled", resource="key", success=True, destructive=True) + if state.json: + output.emit_json({"name": key.name, "status": "disabled"}) + else: + output.key_disabled(key.name) + + +def keys_regenerate( + ctx: typer.Context, + name: str = typer.Argument(..., help="Key name to rotate the secret for (unique within the org)."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Rotate an API key's secret by name and reveal the new secret **once**. + + Resolves the unique key name, confirms (amber, default no), then rotates — the old secret + stops working immediately. The new secret is shown once: piping captures just the raw + secret (`fp keys regenerate admin -y | pbcopy`); interactively it's shown in a box. + Needs `keys:regenerate`. With `--json`: `{"name", "key"}` — the new secret under `key`, the + same field `keys create` uses (or `{"cancelled": true}` on a declined prompt). + + Example: + + * `fp keys regenerate "ci-bot" -y` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + key = _resolve_key_or_exit(state, api.list_keys(cctx), name) + if not _write.confirm_destructive( + state, "regenerate the secret for key", key.name, + consequence="this revokes the current secret immediately and can't be undone", + assume_yes=yes, + ): + if state.json: + output.emit_json({"cancelled": True}) + else: + output.print_cancelled() + return + secret = api.regenerate_key(cctx, key.id) + _write.record_action("api_key_regenerated", resource="key", success=True) + if state.json: + output.emit_json({"name": key.name, "key": secret}) + return + if sys.stdout.isatty(): + output.render_secret_box(key.name, secret) # pretty box (with secret) → stderr + else: + print(secret) # piped/redirected → bare secret to stdout, capturable + + +_KEYS_GROUP_HELP = """Provision and manage API keys — list, inspect, create, and adjust their permissions. + +Keys are referenced by **name** (unique in the org). A key carries a flat set of permissions; +grant them from a role (`--permission-set`) plus `--add` / `--remove` overrides — the same +language as `users`. The secret is shown **once**, at create / regenerate. + +**Subcommands:** `list` · `show` · `create` · `update` · `disable` · `regenerate` + +**Examples:** + +* `fp keys list` — all keys, active first +* `fp keys show ci-bot` — one key's identity + full grants +* `fp keys create ci-bot --permission-set read-only` — invite a key with a role +* `fp keys update ci-bot --add keys:read --remove agent:use` — tweak grants +* `fp keys regenerate ci-bot -y` — rotate the secret (shown once) +* `fp keys disable ci-bot` — revoke it +""" + + +def register(app: typer.Typer) -> None: + keys_app = typer.Typer( + no_args_is_help=True, + rich_markup_mode="markdown", + context_settings={"help_option_names": ["-h", "--help"]}, + help=_KEYS_GROUP_HELP, + ) + keys_app.command("list", epilog=GLOBALS_EPILOG)(keys_list) + keys_app.command("show", epilog=GLOBALS_EPILOG)(keys_show) + keys_app.command("create", epilog=GLOBALS_EPILOG)(keys_create) + keys_app.command("update", epilog=GLOBALS_EPILOG)(keys_update) + keys_app.command("disable", epilog=GLOBALS_EPILOG)(keys_disable) + keys_app.command("regenerate", epilog=GLOBALS_EPILOG)(keys_regenerate) + app.add_typer(keys_app, name="keys") diff --git a/fp-cli/fp_cli/commands/list_cmds.py b/fp-cli/fp_cli/commands/list_cmds.py new file mode 100644 index 000000000..b85092abe --- /dev/null +++ b/fp-cli/fp_cli/commands/list_cmds.py @@ -0,0 +1,63 @@ +"""fp list — discover the distinct values behind the dashboard's filter dropdowns. + +Each subcommand returns a flat list of strings for one facet (the same data that powers the +dashboard's dropdowns), handy for finding valid filter values to pass to `sessions` / `events`. +Every value comes from a per-org cached endpoint, so it stays cheap to call. +""" + +from __future__ import annotations + +import typer + +from .. import client as api +from .. import output +from .._context import GLOBALS_EPILOG, AppState, require_auth + +# Friendly `list <name>` -> (facet key in client._FACET_PATHS, required permission, one-liner, +# title description). The order here is the order shown in `fp list --help`. +_LIST_KINDS = { + "envs": ("environments", "events:read or evaluations:read", "Environments seen across events.", "seen across events"), + "agents": ("agent_ids", "events:read or evaluations:read", "Agent ids seen across events.", "seen across events"), + "event_types": ("event_types", "events:read or evaluations:read", "Event types (agent_start, tool_use, error, …).", "seen across events"), + "score_filters": ("score_filters", "evaluations:read", "Evaluation score keys / metrics (for `--score KEY:MIN..MAX`).", "evaluation score keys"), + "models": ("models", "events:read", "Model names seen across events.", "seen across events"), + "hooks": ("hook_names", "events:read", "Hook names seen across events.", "seen across events"), + "tools": ("tool_names", "events:read", "Tool names seen across events.", "seen across events"), + "error_types": ("error_types", "events:read", "Error types seen across events.", "seen across events"), +} + +def _make_list_cmd(name: str, facet_key: str, perm: str, summary: str, description: str): + """Build the handler for one `list <name>` subcommand (a thin wrapper over the shared + facet fetch, so all nine share one code path).""" + + def _cmd(ctx: typer.Context) -> None: + state: AppState = ctx.obj + cctx = require_auth(state) + values = api.list_facet(cctx, facet_key) + if state.json: + output.emit_json({"kind": name, "values": values}) + return + output.render_value_list(name, values, description=description) + + _cmd.__name__ = f"list_{name}" + _cmd.__doc__ = ( + f"{summary}\n\n" + f" Needs `{perm}`. With `--json`: `{{\"kind\": \"{name}\", \"values\": [...]}}`.\n\n" + f" Example:\n\n" + f" * `fp --json list {name} | jq '.values'`" + ) + return _cmd + + +def register(app: typer.Typer) -> None: + list_app = typer.Typer( + no_args_is_help=True, + rich_markup_mode="markdown", + context_settings={"help_option_names": ["-h", "--help"]}, + help="List the distinct values behind the dashboard's filter dropdowns. Subcommands: " + "**envs**, **agents**, **event_types**, **score_filters**, **models**, **hooks**, " + "**tools**, **error_types**.", + ) + for name, (facet_key, perm, summary, description) in _LIST_KINDS.items(): + list_app.command(name, epilog=GLOBALS_EPILOG)(_make_list_cmd(name, facet_key, perm, summary, description)) + app.add_typer(list_app, name="list") diff --git a/fp-cli/fp_cli/commands/orgs_cmds.py b/fp-cli/fp_cli/commands/orgs_cmds.py new file mode 100644 index 000000000..683b92739 --- /dev/null +++ b/fp-cli/fp_cli/commands/orgs_cmds.py @@ -0,0 +1,319 @@ +"""orgs list / orgs switch / orgs current — discover, select, and inspect the active tenant. + +Org membership comes from the session (no dedicated list endpoint), so the +listing reads it via ``GET /api/auth/session``. ``switch`` persists the chosen +slug to ``~/.failproofai/fpcli/cli-auth.json`` so later commands send it as the +``X-AgentEye-Org`` header; ``current`` reports the tenant in effect right now. +""" + +from __future__ import annotations + +import difflib +from typing import Optional + +import typer + +from .. import _click_compat as click # the Click Typer is running; see _click_compat +from .. import analytics +from .. import config as cfgmod +from .. import orgs as orgsmod +from .. import output +from .. import select as selectmod +from .._context import GLOBALS_EPILOG, AppState, build_context, deny_in_key_mode +from ..client import get_session_user, org_is_accessible +from ..errors import AuthError + +# Every subcommand here reads or writes the SESSION's org memberships, which an API key +# does not have: memberships belong to a person, and `GET /auth/session` (their only +# source) is deliberately absent from the versioned API. Refuse before any network call +# rather than let a 404 on a path that does not exist explain it. +_KEY_MODE_REASON = ( + "org membership belongs to a signed-in user; a key already acts for one org " + "(pass --org <slug> to target another)" +) + + +def _require_session(state: AppState): + """Fetch the session user, or emit a friendly 'not logged in' and exit 4-style.""" + if not state.token: + raise AuthError("Not logged in. Run fp login.") + return get_session_user(build_context(state)) + + +def _active_org(state: AppState, user) -> Optional[str]: + """The effective active org: the explicit/saved value, else your sole org.""" + active = state.org + if not active and len(user.memberships) == 1: + active = user.memberships[0].org_slug + return active + + +def _persist_org(state: AppState, slug: str) -> None: + """Persist ``slug`` as the active tenant (no rendering — the caller chooses the output).""" + state.config.org = slug + cfgmod.save_config(state.config) + analytics.capture("org_selected") + + +def _switch_to(state: AppState, slug: str, *, prev: Optional[str], perm_count: Optional[int]) -> None: + """Persist the chosen org and render the result — the boxed 'switched org' card (or the + ``{"active_org"}`` payload under ``--json``). Shared by the positional + interactive paths so + both render identically.""" + _persist_org(state, slug) + if state.json: + output.emit_json({"active_org": slug}) + else: + output.render_switched_org(slug=slug, prev_slug=prev, perm_count=perm_count) + + +def _render_orgs(state: AppState) -> None: + """Shared body of ``orgs list`` — your orgs + your role in each.""" + user = _require_session(state) + active = _active_org(state, user) + + if state.json: + output.emit_json( + { + "active_org": active, + "is_instance_admin": user.is_instance_admin, + "orgs": [ + { + "org_slug": m.org_slug, + "org_name": m.org_name, + "permission_set": m.permission_set, + "permissions": m.permissions, + "active": m.org_slug == active, + } + for m in user.memberships + ], + } + ) + return + + if not user.memberships: + output.info(" You are not a member of any org.") + if user.is_instance_admin: + output.hint(" Instance admin — pass --org <slug> to act in a specific org.") + return + output.render_orgs_list([ + { + "is_active": m.org_slug == active, + "slug": m.org_slug, + "name": m.org_name, + "role": m.permission_set or "custom", + "perms": len(m.permissions), + } + for m in user.memberships + ]) + + +# ── commands ──────────────────────────────────────────────────────────────── + + +def org_list(ctx: typer.Context) -> None: + """List the orgs you belong to, with your role in each (the active one is marked). + + Reads your memberships from the current session — for each org it shows the org + slug + name, your permission set (role), and how many permissions you hold there. + The active org (`--org`/`FP_ORG`/saved, or your sole org) is marked `●`. With + `--json`: `{"active_org", "is_instance_admin", "orgs":[{"org_slug","org_name", + "permission_set","permissions","active"}]}`. + + Example: + + * `fp orgs list` + * `fp --json orgs list` + """ + deny_in_key_mode(ctx.obj, "orgs list", _KEY_MODE_REASON) + _render_orgs(ctx.obj) + + +def _active_membership(user, active): + """The membership for the active org (or None — e.g. an instance admin acting via --org).""" + return next((m for m in user.memberships if m.org_slug == active), None) + + +def org_current(ctx: typer.Context) -> None: + """Show the org/tenant you're acting as right now — a compact identity card. + + The active org is the global `--org`/`FP_ORG`, else the saved default, else your sole + org if you belong to just one. The card shows the slug + name, your role, your permission + count, and the signed-in email; see the permissions themselves with `fp orgs perms`. + If you haven't picked an org it tells you to run `fp orgs switch`. With `--json`: + `{"slug", "name", "role", "permission_count", "user_email"}`. + + Example: + + * `fp orgs current` + * `fp --json orgs current` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "orgs current", _KEY_MODE_REASON) + user = _require_session(state) + active = _active_org(state, user) + m = _active_membership(user, active) + role = (m.permission_set or "custom") if m else ("instance admin (not a member)" if active else None) + perm_count = len(m.permissions) if m else 0 + + if state.json: + output.emit_json( + { + "slug": active, + "name": m.org_name if m else None, + "role": role, + "permission_count": perm_count, + "user_email": user.email, + } + ) + return + + if not active: + output.info(" No active org selected.") + output.hint(" Run fp orgs switch to choose one.") + return + output.render_current_org( + slug=active, name=m.org_name if m else None, role=role or "—", + permission_count=perm_count, email=user.email, + ) + + +def org_perms(ctx: typer.Context) -> None: + """Show your permissions in the active org — grouped by resource, coloured by risk. + + The same grouped view as `fp whoami`, scoped to the active org: one row per resource + with its actions (read = green, create/modify = pink, invoke = amber, destructive = red). + With `--json`: `{"slug", "role", "permissions", "permission_count"}` (the flat grant list). + + Example: + + * `fp orgs perms` + * `fp --json orgs perms` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "orgs perms", _KEY_MODE_REASON) + user = _require_session(state) + active = _active_org(state, user) + m = _active_membership(user, active) + + if state.json: + output.emit_json( + { + "slug": active, + "role": (m.permission_set or "custom") if m else None, + "permissions": m.permissions if m else [], + "permission_count": len(m.permissions) if m else 0, + } + ) + return + + if not active: + output.info(" No active org selected.") + output.hint(" Run fp orgs switch to choose one.") + return + if not m: + output.info(f" You are not a member of '{active}' (acting as instance admin).") + output.hint(" Instance admins hold no per-org grants; switch to an org you belong to.") + return + output.render_org_perms(slug=active, role=m.permission_set or "custom", + permissions=m.permissions, name=m.org_name) + + +def org_switch( + ctx: typer.Context, + slug: Optional[str] = typer.Argument( + None, help="Org/tenant slug to switch to. Omit it to pick from an arrow-key list (in a terminal)." + ), +) -> None: + """Switch the active org/tenant — pass a slug, or omit it to pick one interactively. + + `fp orgs switch acme` switches straight to `acme`. `fp orgs switch` with no slug + opens an arrow-key picker (↑↓ to move, ⏎ to select, esc to cancel) starting on your current + org; if you belong to exactly one org there's nothing to switch to. Either way the result is + a boxed `switched org` card, and the choice is persisted to + `~/.failproofai/fpcli/cli-auth.json` so later + commands send it as the active tenant. + + A non-interactive run (piped stdin / CI) falls back to a numbered prompt. With `--json` a slug + is required and the output is just `{"active_org": "<slug>"}` — no card. + + Examples: + + * `fp orgs switch acme` — switch straight to acme + * `fp orgs switch` — pick from an arrow-key list + * `fp --json orgs switch acme | jq` — switch and capture the active org + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "orgs switch", _KEY_MODE_REASON) + user = _require_session(state) + current = _active_org(state, user) + + def _perms_for(s: str) -> Optional[int]: + return next((len(m.permissions) for m in user.memberships if m.org_slug == s), None) + + # ── positional form: resolve + switch directly (same card as the picker) ── + if slug is not None: + accessible = slug in user.org_slugs or ( + user.is_instance_admin + and orgsmod.is_valid_org_slug(slug) + and org_is_accessible(build_context(state), slug) + ) + if not accessible: + # Raise a usage error (exit 2) the central chokepoint renders the same way under + # --json (a JSON envelope on stdout) and for humans (a red box on stderr) — the old + # code emitted nothing under --json, so a script got a bare exit 2 with no reason. + match = difflib.get_close_matches(slug, user.org_slugs, n=1, cutoff=0.6) + err = click.UsageError(f"no org named {slug}") + err.hint = ( + f"did you mean {match[0]}" if match else "run `fp orgs list` to see your orgs" + ) + raise err + if slug == current: + output.emit_json({"active_org": slug}) if state.json else output.org_already_on(slug) + return + _switch_to(state, slug, prev=current, perm_count=_perms_for(slug)) + return + + # ── no slug → discover ── + slugs = user.org_slugs + if not slugs: + output.emit_json({"active_org": current}) if state.json else \ + output.org_none_available(instance_admin=user.is_instance_admin) + return + if len(slugs) == 1: + _persist_org(state, slugs[0]) + output.emit_json({"active_org": slugs[0]}) if state.json else output.org_only_one(slugs[0]) + return + if state.json: + # No card to render in json mode and no way to pick → require an explicit slug. + raise typer.BadParameter( + "No org given. With --json pass a slug, e.g. `fp --json orgs switch <slug>`." + ) + + # Pick: an arrow-key picker on a real TTY, else a numbered prompt (reads piped input; + # aborts cleanly on empty stdin) — both inside choose_org_interactive. + orgs_view = [{"slug": m.org_slug, "is_current": m.org_slug == current} for m in user.memberships] + chosen = selectmod.choose_org_interactive(orgs_view, current_slug=current) + if chosen is None: + output.org_switch_cancelled(current) + return + if chosen == current: + output.org_already_on(chosen) + return + _switch_to(state, chosen, prev=current, perm_count=_perms_for(chosen)) + + +def register(app: typer.Typer) -> None: + _ctx = {"help_option_names": ["-h", "--help"]} + + # A single `orgs` group holding all tenant functionality. + orgs_app = typer.Typer( + no_args_is_help=True, + rich_markup_mode="markdown", + context_settings=_ctx, + help="Inspect and select the active org/tenant. Subcommands: **list**, **switch**, **current**, **perms**.", + ) + orgs_app.command("list", epilog=GLOBALS_EPILOG)(org_list) + orgs_app.command("switch", epilog=GLOBALS_EPILOG)(org_switch) + orgs_app.command("current", epilog=GLOBALS_EPILOG)(org_current) + orgs_app.command("perms", epilog=GLOBALS_EPILOG)(org_perms) + app.add_typer(orgs_app, name="orgs") diff --git a/fp-cli/fp_cli/commands/policies_cmds.py b/fp-cli/fp_cli/commands/policies_cmds.py new file mode 100644 index 000000000..37cd59695 --- /dev/null +++ b/fp-cli/fp_cli/commands/policies_cmds.py @@ -0,0 +1,465 @@ +"""Cloud-managed policies: policies list / show / publish / enable / disable / delete. + +Where a policy VERSION is written. Deploying one to a machine is `fp fleet`, and +seeing what it actually did is `fp guardrails` — three commands because they are +three jobs, done by different people at different times, exactly as the +dashboard splits them across three pages. + +Publishing mints a new version and changes nothing on any machine. That is the +single most surprising thing here, so every success path says so. +""" +from __future__ import annotations + +from typing import Optional + +import typer + +from .. import client as api +from .. import output +from .._context import GLOBALS_EPILOG, AppState, deny_in_key_mode, require_auth +from .. import _click_compat as click # the Click Typer is running; see _click_compat +from ..enforcement import RefError, RefUsageError, read_source +from ..policy_check import check_syntax, run_policy +from ..errors import ApiError, NotFoundError +from . import _write + +#: Every command here is session-only. These endpoints are ROOT-ONLY on the +#: server — deliberately absent from `/v1`, because `/v1` is internet-facing and +#: publish/deploy/rollback are operator writes. Failing here beats translating a +#: path that would 404 with no explanation. +_KEY_MODE_REASON = ( + "cloud-managed policies are an operator surface and are not exposed on the " + "versioned API that an API key authenticates against" +) + + +def policies_list(ctx: typer.Context) -> None: + """List every published policy version, newest of each policy first. + + Shows `policy · version · state · description`, one row per VERSION — + versions are immutable and every one stays addressable, so a policy + published three times is three rows. The title counts distinct policies and + captions the version total, the way the dashboard's library does. + + `state` is active, disabled (kept but not enforced) or archived (deleted; + machines already carrying it keep it until redeployed). Needs + `policies:read`. With `--json`: the server's policy list verbatim — also + every version, so deduplicate on `id` if you want one row per policy. + + Example: + + * `fp policies list` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "policies", _KEY_MODE_REASON) + cctx = require_auth(state) + items = api.list_policies(cctx) + if output.is_json(): + output.emit_json({"policies": [p.to_dict() for p in items]}) + return + output.render_policies(items) + + +def policies_show( + ctx: typer.Context, + policy_id: str = typer.Argument(..., help="Policy id."), +) -> None: + """Show one policy, including its full source. + + Needs `policies:read`. With `--json`: the policy object with `source`. + + Example: + + * `fp policies show no-force-push` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "policies show", _KEY_MODE_REASON) + cctx = require_auth(state) + # Explicitly the newest version, not the first one the server happened to + # list. `next(...)` returned whichever came back first, so the source shown + # was correct only for as long as the endpoint kept returning descending + # versions — and a stale source rendered identically to a current one. + versions = [p for p in api.list_policies(cctx) if p.id == policy_id] + if not versions: + raise NotFoundError(f"no policy named {policy_id}") + match = max(versions, key=lambda p: p.version) + if output.is_json(): + output.emit_json(match.to_dict()) + return + carriers = { + d.machine_id: ref.version + for d in api.list_deployments(cctx) + for ref in d.policies + if ref.id == policy_id + } + output.render_policy_published(match, carriers=carriers, + source_bytes=len((match.source or "").encode("utf-8"))) + if match.source: + output.info(match.source) + + +def policies_publish( + ctx: typer.Context, + policy_id: str = typer.Argument(..., help="Policy id (letters, numbers, '.', '_', '-')."), + source: Optional[str] = typer.Argument( + None, + help="Path to the policy source, @path, or - for stdin. Omit to paste it.", + ), + description: str = typer.Option("", "--description", help="One-line description."), + no_verify: bool = typer.Option( + False, "--no-verify", help="Skip the JavaScript syntax check before publishing." + ), +) -> None: + """Publish a policy — mints a NEW VERSION; it never edits one in place. + + The source is parse-checked with node before it is sent. Nothing downstream + does this: the server validates the id and a size ceiling, and a broken + policy otherwise fails on the machine at enforcement time. `--no-verify` + skips it; a host without node publishes with a warning rather than a block. + + Source can come from a path, `@path`, a pipe, `-`, or an interactive paste + when you give none and stdin is a terminal. + + **Publishing deploys nothing.** A new version sits unused until + `fp fleet deploy` puts it on a machine. Needs `policies:write`. + With `--json`: the created version plus `carriers` — a map of machine id to + the version of this policy it currently runs, so a harness can tell what a + publish left behind without a second call. + + Examples: + + * `fp policies publish no-force-push ./rule.mjs` + * `cat rule.mjs | fp policies publish no-force-push` + * `fp policies publish no-force-push -` — read stdin explicitly + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "policies publish", _KEY_MODE_REASON) + cctx = require_auth(state) + + def _paste_prompt() -> None: + output.hint("paste the policy source, then press Ctrl-D") + + try: + text = read_source(source, prompt=_paste_prompt) + except RefUsageError as exc: + raise click.UsageError(str(exc)) + except RefError as exc: + raise ApiError(str(exc)) + if not text.strip(): + raise ApiError("policy source is empty — nothing to publish") + + # Nothing downstream parses this. The server checks the id and a size + # ceiling; the machines find out at enforcement time, which is the worst + # place for a syntax error to surface. `--no-verify` exists because a + # machine without node should still be able to publish, not because + # skipping is ever a good idea. + if not no_verify: + syn = check_syntax(text) + if not syn.ok: + raise ApiError( + f"{policy_id} is not parseable JavaScript — refusing to publish it:\n" + f"{syn.message}", + hint="fix the syntax, or pass --no-verify to publish it anyway", + ) + if not syn.checked and not output.is_json(): + output.warn(syn.message) + + created = api.publish_policy(cctx, policy_id, text, description) + + # Which machines already carry this policy, and at which version. Publishing + # deploys nothing, so this is the one thing the card must not guess at: it + # used to state "not deployed anywhere" unconditionally, which was wrong for + # every policy that already had a version in the field. + carriers = { + d.machine_id: ref.version + for d in api.list_deployments(cctx) + for ref in d.policies + if ref.id == policy_id + } + if output.is_json(): + output.emit_json({**created.to_dict(), "carriers": carriers}) + return + output.render_policy_published(created, carriers=carriers, + source_bytes=len(text.encode("utf-8"))) + + +def policies_enable( + ctx: typer.Context, + policy_id: str = typer.Argument(..., help="Policy id."), +) -> None: + """Re-enable a disabled policy, restoring it to the machines that lost it. + + The exact inverse of `disable`: the server puts the policy back into every + deployment it was removed from, advancing each machine's generation again. + Nothing needs redeploying by hand. + + Needs `policies:write`. With `--json`: `{id, disabled, archived, + machinesUpdated}` — `machinesUpdated` counts the deployments rewritten, and + matches the count the preceding `disable` reported. + + Example: + + * `fp policies enable no-force-push` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "policies enable", _KEY_MODE_REASON) + cctx = require_auth(state) + res = api.set_policy_enabled(cctx, policy_id, True) + if output.is_json(): + output.emit_json(res) + return + output.policy_lifecycle_changed(policy_id, "enabled") + + +def policies_disable( + ctx: typer.Context, + policy_id: str = typer.Argument(..., help="Policy id."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Disable a policy. It is removed from every deployment carrying it. + + Not just "machines stop enforcing it": the server reissues each affected + machine's deployment WITHOUT this policy, advancing that machine's + generation. `fp fleet history` shows the reissue as an ordinary entry. + + `policies enable` is the exact inverse: it puts the policy back into every + deployment it was removed from, so nothing needs redeploying by hand. + + Needs `policies:write`. With `--json`: `{id, disabled, archived, + machinesUpdated}` — `machinesUpdated` is how many deployments were rewritten + to drop it, and is the number to check if you expected this to be a no-op. + + Example: + + * `fp policies disable no-force-push --yes` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "policies disable", _KEY_MODE_REASON) + cctx = require_auth(state) + if not _write.confirm_destructive( + state, "disable policy", policy_id, + consequence=("it is REMOVED from every deployment carrying it, minting a new " + "generation on each; `policies enable` puts it back the same way"), + assume_yes=yes, + ): + if output.is_json(): + output.emit_json({"cancelled": True}) + else: + output.print_cancelled() + return + res = api.set_policy_enabled(cctx, policy_id, False) + if output.is_json(): + output.emit_json(res) + return + output.policy_lifecycle_changed(policy_id, "disabled") + + +def policies_delete( + ctx: typer.Context, + policy_id: str = typer.Argument(..., help="Policy id."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Archive a policy. This cannot be undone from the CLI. + + Archiving hides it from `policies list` and from future deployments. A + machine already carrying it keeps enforcing it until something redeploys — + deleting is not a way to stop enforcement everywhere, and `disable` is. + + Needs `policies:write`. With `--json`: `{id, disabled, archived, + machinesUpdated}`, or `{"cancelled": true}` if you decline. + + Example: + + * `fp policies delete old-rule --yes` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "policies delete", _KEY_MODE_REASON) + cctx = require_auth(state) + if not _write.confirm_destructive( + state, "archive policy", policy_id, + consequence=("machines already carrying it keep enforcing until redeployed — " + "`policies disable` is what stops enforcement"), + assume_yes=yes, + ): + if output.is_json(): + output.emit_json({"cancelled": True}) + else: + output.print_cancelled() + return + res = api.delete_policy(cctx, policy_id) + if output.is_json(): + output.emit_json(res) + return + output.policy_lifecycle_changed(policy_id, "archived") + + +def policies_test( + ctx: typer.Context, + source: Optional[str] = typer.Argument( + None, help="Policy file, @path, or - for stdin. Omit to paste it." + ), + tool: str = typer.Option("Bash", "--tool", help="Tool name the hook fired for."), + command: Optional[str] = typer.Option(None, "--command", help="Bash command to test against."), + file_path: Optional[str] = typer.Option(None, "--file", help="File path to test against."), + event: str = typer.Option("PreToolUse", "--event", help="Hook event type."), + expect: Optional[str] = typer.Option( + None, "--expect", + help="Assert the decision is allow/deny/instruct; exit 1 if it is not.", + ), +) -> None: + """Run a policy locally and print what it would decide. No server, no fleet. + + Executes the real file — bare `import { deny } from "failproofai"` and all — + against a context you describe, and prints allow / deny / instruct per + registered policy. Nothing is published and nothing is installed. + + Needs `node` on PATH. This is a dry run, not the enforcement path: it proves + the policy parses, registers and decides for the input given. It cannot + prove the daemon feeds it the same context. + + With `--json`: `{ok, decision, policies:[{name, decision, reason}]}` — the + overall `decision` is the strictest any policy returned. + + Examples: + + * `fp policies test ./rule.mjs --command "git push --force"` + * `fp policies test ./rule.mjs --tool Write --file .env` + """ + # No `require_auth` and no `deny_in_key_mode`: this command talks to node, + # not to the dashboard, so it works logged out and under an API key alike. + + def _paste_prompt() -> None: + output.hint("paste the policy source, then press Ctrl-D") + + try: + text = read_source(source, prompt=_paste_prompt) + except RefUsageError as exc: + raise click.UsageError(str(exc)) + except RefError as exc: + raise ApiError(str(exc)) + if not text.strip(): + raise ApiError("policy source is empty — nothing to test") + + # Checked before the syntax check runs, so a bad --expect reports itself + # rather than being masked by whatever node says about the file. A usage + # error should never depend on the content of an argument. + if expect is not None and expect not in ("allow", "deny", "instruct"): + raise typer.BadParameter( + f"invalid --expect value {expect!r}; choose one of allow, deny, instruct" + ) + + syn = check_syntax(text) + if not syn.ok: + if output.is_json(): + output.emit_json({"ok": False, "syntax": syn.to_dict(), "policies": []}) + raise typer.Exit(1) + raise ApiError(f"the policy is not parseable JavaScript:\n{syn.message}") + + run = run_policy(text, tool=tool, command=command, file_path=file_path, event=event) + + # A policy that correctly denies is a SUCCESSFUL test, so the decision does + # not set the exit code on its own — otherwise `policies test` would fail + # whenever the policy worked. `--expect` is how CI asserts instead: it turns + # "what did it decide" into "did it decide what I meant". + met = expect is None or run.decision == expect + if output.is_json(): + output.emit_json({**run.to_dict(), "syntax": syn.to_dict(), + "expected": expect, "met": met}) + raise typer.Exit(0 if (run.ok and met) else 1) + if not run.ok: + raise ApiError(run.error) + output.render_policy_test(run, tool=tool, command=command, file_path=file_path, + expected=expect) + if not met: + raise typer.Exit(1) + + +def policies_compose( + ctx: typer.Context, + prompt: str = typer.Argument(..., help="What the policy should do, in plain English."), + out: Optional[str] = typer.Option(None, "--out", help="Write the draft to this file."), + publish_as: Optional[str] = typer.Option( + None, "--publish", help="Publish the draft immediately under this policy id." + ), +) -> None: + """Draft a policy from a description, using the Cloud assistant. + + The assistant writes the source; **you** decide whether it ships. By default + the draft is printed and nothing else happens — a generated policy that + deploys itself is a generated policy nobody read. + + `--out` saves it; `--publish <id>` publishes it, still syntax-checked first. + Needs `agent:use`, and `policies:write` to publish. Session-only. + + With `--json`: `{prompt, source, syntax, published}`. + + Examples: + + * `fp policies compose "block force pushes to main"` + * `fp policies compose "deny reading .env" --out env.mjs` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "policies compose", _KEY_MODE_REASON) + cctx = require_auth(state) + + with output.thinking("drafting…", enabled=not output.is_json()): + res = api.compose_policy(cctx, prompt) + source = (res or {}).get("source") or (res or {}).get("policy") or "" + if not source.strip(): + raise ApiError( + "the assistant returned no policy source", + hint="check `fp agent health` — the assistant may not be configured here", + ) + + syn = check_syntax(source) + + # Saved BEFORE anything that can fail. `--out` used to run after the + # publish, so a publish that was refused — bad syntax, no `policies:write`, + # a network blip — threw away the draft the user had just paid an assistant + # to write, with no way to get that same text back. + if out: + try: + with open(out, "w", encoding="utf-8") as fh: + fh.write(source) + except OSError as exc: + raise ApiError(f"cannot write {out}: {exc.strerror or exc}") + + published = None + if publish_as: + if not syn.ok: + raise ApiError( + f"the drafted policy is not parseable JavaScript — refusing to publish:\n" + f"{syn.message}", + hint=("fix it and publish it with `fp policies publish`" + if out else "save it with --out, fix it, then publish"), + ) + published = api.publish_policy(cctx, publish_as, source, f"drafted: {prompt}"[:500]) + + if output.is_json(): + output.emit_json({ + "prompt": prompt, "source": source, "syntax": syn.to_dict(), + "published": published.to_dict() if published else None, + "savedTo": out, + }) + return + output.render_composed_policy(prompt, source, syn, saved_to=out) + if published: + output.policy_published_brief(published) + + +def register(app: typer.Typer) -> None: + policies_app = typer.Typer( + no_args_is_help=True, + rich_markup_mode="markdown", + context_settings={"help_option_names": ["-h", "--help"]}, + help="Write and manage cloud-managed policies (list / show / publish / enable / disable / delete).", + ) + policies_app.command("list", epilog=GLOBALS_EPILOG)(policies_list) + policies_app.command("show", epilog=GLOBALS_EPILOG)(policies_show) + policies_app.command("publish", epilog=GLOBALS_EPILOG)(policies_publish) + policies_app.command("enable", epilog=GLOBALS_EPILOG)(policies_enable) + policies_app.command("disable", epilog=GLOBALS_EPILOG)(policies_disable) + policies_app.command("delete", epilog=GLOBALS_EPILOG)(policies_delete) + policies_app.command("test", epilog=GLOBALS_EPILOG)(policies_test) + policies_app.command("compose", epilog=GLOBALS_EPILOG)(policies_compose) + app.add_typer(policies_app, name="policies") diff --git a/fp-cli/fp_cli/commands/queries_cmds.py b/fp-cli/fp_cli/commands/queries_cmds.py new file mode 100644 index 000000000..fa82514dd --- /dev/null +++ b/fp-cli/fp_cli/commands/queries_cmds.py @@ -0,0 +1,378 @@ +"""Saved SQL queries + ad-hoc runner: query list/show/create/update/delete/run/schema. + +Runs against the server's read-only analytics pool. SQL can be inline or read from a +file with ``--sql @path.sql``; run parameters are positional ``$1..$N`` values. +""" + +from __future__ import annotations + +from typing import Any, List, Optional + +import typer + +from .. import _click_compat as click # the Click Typer is running; see _click_compat +from .. import client as api +from .. import output +from .._context import GLOBALS_EPILOG, AppState, require_auth, resolve_fields, validate_limit +from ..errors import NotFoundError +from ..models import SavedQuery +from . import _write + + +def _humanize_list(items: List[str]) -> str: + """``["name","sql","description"]`` → ``name, sql, and description``; one item → itself.""" + if not items: + return "" + if len(items) == 1: + return items[0] + if len(items) == 2: + return f"{items[0]} and {items[1]}" + return ", ".join(items[:-1]) + f", and {items[-1]}" + + +def _read_sql(value: str) -> str: + """Inline SQL, or the contents of a file when given as ``@path.sql``.""" + if value.startswith("@"): + return _write.read_text_arg(value[1:], flag="--sql") + return value + + +def _coerce_value(raw: str) -> Any: + """Best-effort coercion of a run parameter value to int/float/bool/null/str.""" + low = raw.lower() + if low in ("true", "false"): + return low == "true" + if low in ("null", "none"): + return None + try: + return int(raw) + except ValueError: + pass + try: + return float(raw) + except ValueError: + pass + return raw + + +def _resolve_query_or_exit(state: AppState, queries, handle: str) -> SavedQuery: + """Resolve a saved query by **name** (primary), falling back to an exact id match, raising a + typed error the central chokepoint renders: none → exit 6, several → exit 2.""" + return _write.resolve_one(queries, handle, kind="query", plural="queries", list_cmd="query list") + + +def query_list( + ctx: typer.Context, + show_id: bool = typer.Option(False, "--show-id", help="Prepend a short query-id column (the full id is always in --json)."), + fields: Optional[str] = typer.Option(None, "--fields", help="Comma-separated subset of raw fields (falls back to a plain table)."), +) -> None: + """List the org's saved queries in a boxed table, newest first. + + Shows `name · description · created by · created` — the long description is truncated to one + line (full text in `query show` / `--json`), and `created` is when the query was made (not + `updated_at`). `name` is the handle `query run`/`query show` take; the raw id is hidden unless + `--show-id`. Needs `queries:read`. With `--json`: `{"queries": [{id, name, description, + sql_text, params, created_by, created_at, ...}]}`. + + Example: + + * `fp query list` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + queries = api.list_saved_queries(cctx) + cols = resolve_fields(fields, SavedQuery) + if state.json: + payload = output.project_dicts(queries, cols) if cols else queries + output.emit_json({"queries": payload}) + return + if cols: + # `--fields` asks for specific raw columns → the generic table (no bespoke styling). + output.print_table(list(cols), output.project_rows(queries, cols), title=f"Saved queries ({len(queries)})") + return + output.render_queries(queries, show_id=show_id) + + +def query_show( + ctx: typer.Context, + name: str = typer.Argument(..., metavar="NAME", help="Saved query name (or id)."), +) -> None: + """Show one saved query — a metadata card + its full, syntax-highlighted SQL. + + Referenced by query **name** (a UUID-shaped id is also accepted). The SQL is shown in full with + line numbers. Not-found → red `✗ no query named "…"`, exit 6. Needs `queries:read`. With + `--json`: the full `SavedQuery` (raw `sql_text` for piping). + + Example: + + * `fp query show q_eval_score_avg` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + q = _resolve_query_or_exit(state, api.list_saved_queries(cctx), name) + if state.json: + output.emit_json(q) + return + output.render_query_show(q) + + +def query_create( + ctx: typer.Context, + name: str = typer.Argument(..., metavar="NAME", help="Saved query name (unique per org)."), + sql: str = typer.Option(..., "--sql", help="SQL text, or `@file.sql` to read from a file."), + description: str = typer.Option("", "--description", help="Optional one-line description."), +) -> None: + """Create a saved query — give it a **name**, the SQL, and an optional description. + + The query is saved to the org's analytics library; run it later with `fp query run + <name>`. The SQL can be inline or read from a file with `--sql @file.sql`. A name collision is + rejected up front. Needs `queries:write`. With `--json`: the created `SavedQuery` + (`{id, name, description, created_at, sql_text}`). + + Examples: + + * `fp query create top-agents --sql "SELECT agent_id, count() FROM analytics.events GROUP BY agent_id"` + * `fp query create errors-by-env --sql @errs.sql --description "errored events per environment"` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + if any(q.name == name for q in api.list_saved_queries(cctx)): # names are unique per org + raise click.UsageError(f'a query named "{name}" already exists') + result = api.create_saved_query( + cctx, name=name, sql_text=_read_sql(sql), description=description, params=[] + ) + _write.record_action("saved_query_created", resource="query", success=True) + if state.json: + output.emit_json({"id": result.id, "name": result.name, "description": result.description, + "created_at": result.created_at, "sql_text": result.sql_text}) + return + output.render_query_created(result) + + +def query_update( + ctx: typer.Context, + name: str = typer.Argument(..., metavar="NAME", help="Saved query name (or id) to update."), + new_name: Optional[str] = typer.Option(None, "--name", help="Rename the query (defaults to the current name)."), + sql: Optional[str] = typer.Option(None, "--sql", help="New SQL text, or `@file.sql` (defaults to current)."), + description: Optional[str] = typer.Option(None, "--description", help="New description (defaults to current)."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Update a saved query, referenced by **name** (or a UUID-shaped id). + + Change any of `--name`, `--sql`, `--description` (pass at least one); fields you omit keep + their current value (the CLI reads the query first, then saves the merged result). It confirms + first (default no; `--yes` skips) and shows the updated query as a green card + numbered SQL + box. A no-op exits without saving. Needs `queries:write`. With `--json`: the updated + `SavedQuery`. + + Examples: + + * `fp query update top-agents --sql @top.sql` — replace the SQL + * `fp query update top-agents --name agent-totals --description "rows per agent"` — rename + redescribe + """ + state: AppState = ctx.obj + if new_name is None and sql is None and description is None: + raise typer.BadParameter("nothing to update — pass --name, --sql, and/or --description.") + cctx = require_auth(state) + queries = api.list_saved_queries(cctx) + q = _resolve_query_or_exit(state, queries, name) + + # A rename to an existing name collides (like create's 409). + if new_name is not None and new_name != q.name and any(o.name == new_name for o in queries): + raise click.UsageError(f'a query named "{new_name}" already exists') + + # Read `--sql` exactly ONCE. `@-` is stdin, and stdin can only be drained once: a + # second `_read_sql(sql)` for the request body returns "", so change detection would + # compare the real text while the save wrote an empty query — silently, exit 0. + new_sql = _read_sql(sql) if sql is not None else None + + # Which fields actually change → the confirm consequence (and no-op detection). + changed: List[str] = [] + if new_name is not None and new_name != q.name: + changed.append("name") + if new_sql is not None and new_sql != q.sql_text: + changed.append("sql") + if description is not None and description != q.description: + changed.append("description") + if not changed: + if state.json: + output.emit_json({"id": q.id, "name": q.name, "description": q.description, + "created_at": q.created_at, "sql_text": q.sql_text}) + else: + output.query_no_change() + return + + if _write.should_prompt(state, yes): + if not output.confirm_query_update(q.name, _humanize_list(changed)): + if state.json: + output.emit_json({"cancelled": True}) + else: + output.query_cancelled("nothing changed") + return + + result = api.update_saved_query( + cctx, q.id, + name=new_name if new_name is not None else q.name, + sql_text=new_sql if new_sql is not None else q.sql_text, + description=description if description is not None else q.description, + params=q.params, + ) + _write.record_action("saved_query_updated", resource="query", success=True) + if state.json: + output.emit_json({"id": result.id, "name": result.name, "description": result.description, + "created_at": result.created_at, "sql_text": result.sql_text}) + return + output.render_query_updated(result, old_name=q.name) + + +def query_delete( + ctx: typer.Context, + name: str = typer.Argument(..., metavar="NAME", help="Saved query name (or id) to delete."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Delete a saved query, referenced by **name** (or a UUID-shaped id). This cannot be undone. + + Needs `queries:delete`. With `--json`: `{"deleted": true, "id", "name"}`. + + Example: + + * `fp query delete errs --yes` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + q = _resolve_query_or_exit(state, api.list_saved_queries(cctx), name) + if _write.should_prompt(state, yes): + output.render_query_delete_preview(q) # amber preview of what's about to go + if not output.confirm_query_delete(): + if state.json: + output.emit_json({"cancelled": True}) + else: + output.query_cancelled("nothing deleted") + return + api.delete_saved_query(cctx, q.id) + _write.record_action("saved_query_deleted", resource="query", success=True, destructive=True) + if state.json: + output.emit_json({"deleted": True, "id": q.id, "name": q.name}) + else: + output.query_deleted(q.name) + + +def query_run( + ctx: typer.Context, + name: Optional[str] = typer.Argument(None, metavar="[NAME]", help="Saved query name to run (or a UUID-shaped id)."), + sql: Optional[str] = typer.Option(None, "--sql", help="Run ad-hoc SQL instead of a saved query, or `@file.sql`."), + limit: Optional[int] = typer.Option(None, "--limit", help=f"Max rows to show in the table view (default {output.QUERY_RUN_ROW_CAP}; --json always returns all)."), + all_: bool = typer.Option(False, "--all", help="Show every returned row (override the table row cap)."), + param: Optional[List[str]] = typer.Option(None, "--arg", "--param", help="Positional argument value bound to $1..$N, in order (repeatable). Alias: --param."), +) -> None: + """Run a saved query by **name**, or ad-hoc `--sql`, against the read-only analytics pool. + + The result is rendered adaptively from its shape — a scalar stat card, a single record, or a + boxed table (capped to a preview; `--limit`/`--all` adjust it, `--json` returns everything). + Pass a saved query's positional parameters with `--arg` (one per `$1..$N`, in order). Needs + `queries:run`. Not-found → red `✗ no query named "…"`; a SQL/exec error → `✗ query failed — …`. + With `--json`: `{columns: [{name, type}], rows: [[...]], truncated, elapsed_ms}` (all rows). + + Examples: + + * `fp query run q_eval_total` + * `fp query run q_eval_score_avg --arg agent-codegen` + * `fp --json query run --sql "select count(*) from analytics.events"` + """ + state: AppState = ctx.obj + if (name is None) == (sql is None): + raise typer.BadParameter("pass a saved query NAME or --sql (exactly one).") + validate_limit(limit) + cctx = require_auth(state) + query_id: Optional[str] = None + display_name = "query" + if name is not None: + q = _resolve_query_or_exit(state, api.list_saved_queries(cctx), name) + query_id, display_name = q.id, q.name + values = [_coerce_value(v) for v in (param or [])] + # A SQL/exec/permission failure propagates as a typed error to the central chokepoint + # (JSON envelope under --json, red box otherwise). The DB detail is surfaced because + # client._extract_error folds the server's `detail` into the message. + result = api.run_query( + cctx, sql=_read_sql(sql) if sql is not None else None, query_id=query_id, params=values + ) + _write.record_action( + "query_run", success=True, via="saved" if query_id else "ad_hoc", row_count_bucket=_bucket(len(result.rows)) + ) + if state.json: + output.emit_json(result) + return + row_cap = limit if limit is not None else output.QUERY_RUN_ROW_CAP + output.render_query_result(display_name, result, row_cap=row_cap, show_all=all_) + + +def _bucket(n: int) -> str: + if n == 0: + return "0" + if n <= 10: + return "1-10" + if n <= 100: + return "11-100" + return "100+" + + +def query_schema( + ctx: typer.Context, + table: Optional[str] = typer.Argument(None, metavar="[TABLE]", help="Filter to one table's columns."), +) -> None: + """Show the queryable analytics schema in a boxed, table-grouped view. + + Each table's name prints once (then blanks down its columns); types are coloured by category + (numeric / string / uuid+timestamp / bool) with a dim `?` for nullable. Pass a `TABLE` to + filter to one. Needs `queries:read`. With `--json`: `{schema, columns: [{table, column, type, + nullable}]}` (the `?` split into a boolean). + + Examples: + + * `fp query schema` + * `fp query schema events` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + data = api.query_schema(cctx) + tables = list(data.get("tables", []) or []) + if table is not None: + match = [t for t in tables if t.get("name") == table] + if not match: + names = [str(t.get("name", "")) for t in tables] + raise NotFoundError( + f'no table named "{table}"', hint=f"available: {', '.join(names)}" if names else None + ) + tables = match + data = {**data, "tables": tables} + + if state.json: + flat = [] + for t in tables: + for col in t.get("columns", []) or []: + ty = str(col.get("type", "")) + nullable = ty.endswith("?") + flat.append({"table": t.get("name", ""), "column": col.get("name", ""), + "type": ty[:-1] if nullable else ty, "nullable": nullable}) + output.emit_json({"schema": data.get("schema", ""), "columns": flat}) + return + output.render_query_schema(data) + total_cols = sum(len(t.get("columns", []) or []) for t in tables) + output.schema_footer(len(tables), total_cols) + + +def register(app: typer.Typer) -> None: + query_app = typer.Typer( + no_args_is_help=True, + rich_markup_mode="markdown", + context_settings={"help_option_names": ["-h", "--help"]}, + help="Run and manage saved SQL queries (list / show / create / update / delete / run / schema).", + ) + query_app.command("list", epilog=GLOBALS_EPILOG)(query_list) + query_app.command("show", epilog=GLOBALS_EPILOG)(query_show) + query_app.command("create", epilog=GLOBALS_EPILOG)(query_create) + query_app.command("update", epilog=GLOBALS_EPILOG)(query_update) + query_app.command("delete", epilog=GLOBALS_EPILOG)(query_delete) + query_app.command("run", epilog=GLOBALS_EPILOG)(query_run) + query_app.command("schema", epilog=GLOBALS_EPILOG)(query_schema) + app.add_typer(query_app, name="query") diff --git a/fp-cli/fp_cli/commands/sessions_cmds.py b/fp-cli/fp_cli/commands/sessions_cmds.py new file mode 100644 index 000000000..95cba50c2 --- /dev/null +++ b/fp-cli/fp_cli/commands/sessions_cmds.py @@ -0,0 +1,144 @@ +"""sessions — list agent sessions (newest first) with their run status.""" + +from __future__ import annotations + +from typing import List, Optional + +import typer + +from .. import client as api +from .. import dates, output +from .._context import ( + GLOBALS_EPILOG, + AppState, + collect_multi, + require_auth, + resolve_dates, + resolve_fields, + validate_choice, + validate_limit, +) +from ..models import Session + + +def sessions( + ctx: typer.Context, + limit: int = typer.Option(50, "--limit", "-n", help="Max rows in total. Use --all to auto-paginate beyond the server's single-request cap."), + since: Optional[str] = typer.Option(None, "--since", help=f"Relative window from now (dashboard presets): {', '.join(dates.SINCE_CHOICES)}."), + ts_from: Optional[str] = typer.Option(None, "--from", help="Custom-range start, ISO-8601 UTC (e.g. 2026-05-01T00:00:00Z). Overrides --since."), + ts_to: Optional[str] = typer.Option(None, "--to", help="Custom-range end, ISO-8601 UTC. Overrides --since."), + environment: Optional[List[str]] = typer.Option(None, "--env", help="Filter by environment. Accepts multiple — repeat the flag or comma-separate: `--env prod --env staging` or `--env prod,staging` (matches any)."), + status: Optional[List[str]] = typer.Option(None, "--status", help="Filter by run status (`done`/`error`/`timeout`), matched against each session's latest evaluation. Accepts multiple — repeat the flag or comma-separate: `--status error,timeout` (matches any)."), + agent_id: Optional[List[str]] = typer.Option(None, "--agent-id", help="Filter by agent id. Accepts multiple — repeat the flag or comma-separate: `--agent-id a,b` (matches any)."), + session_id: Optional[List[str]] = typer.Option(None, "--session-id", help="Filter by session id. Accepts multiple — repeat the flag or comma-separate: `--session-id a,b` (matches any)."), + fetch_all: bool = typer.Option(False, "--all", help="Auto-paginate through all pages, up to --limit."), + cursor: Optional[str] = typer.Option(None, "--cursor", help="Resume after this cursor (a prior next_cursor; opaque token)."), + page_size: Optional[int] = typer.Option(None, "--page-size", help="Rows per request when --all (max 200)."), + fields: Optional[str] = typer.Option(None, "--fields", help="Comma-separated subset of fields to output (applies to --json and the table). e.g. `session_id,status,last_event_at`."), + full_ids: bool = typer.Option(False, "--full-ids", help="Show full session ids in the table instead of truncating them (--json always has the full id)."), + agents_expand: bool = typer.Option(False, "--agents", help="Expand every multi-agent session into an indented roster of its agents (name + event count). Rendered view only — `--json` always carries the full `agents` list."), +) -> None: + """List **sessions** — one row per agent run, newest first. + + Shows what ran and how it ended: `time · env · agent · session · status`. `status` is the + session's latest evaluation outcome (`done`/`error`/`timeout`, blank if it was never + evaluated). For the per-evaluation **scores** behind a session — and rolled-up score stats — + use `fp evals`. + + The filters `--env`, `--status`, `--agent-id`, and `--session-id` each accept **multiple + values** — repeat the flag or comma-separate (`--status error,timeout` is the same as + `--status error --status timeout`). Values within one filter match **any** of them; different + filters are combined (a session must satisfy all of them). Scope by time with `--since` (a + preset window) or `--from`/`--to` (a custom UTC range), and page with `--all` or `--cursor`. + + A session can involve **more than one agent**. The `agent` column shows the root agent (the + first to start) plus a `+N` badge counting the others; `--agents` expands every multi-agent + session into an indented roster (each agent + its event count). `--agent-id` matches a session + if **any** of its agents is one you named (not just the root). + + Needs `evaluations:read`. With `--json`: `{"sessions": [...], "next_cursor": <cursor or null>}`. + Each row has `session_id, agent_id, agents, environment, status, scores, event_count, + started_at, last_event_at, first_event_id, last_event_id, latest_evaluation` (also the valid + `--fields` names) — `agent_id` is the root agent and `agents` is the full roster (each + `{agent_id, event_count}`, sorted by event count); `status` and `scores` are flattened up from + the latest evaluation for convenience, and the full evaluation is kept under `latest_evaluation`. + + Examples: + + * `fp sessions --env prod,staging --status error,timeout` — recent prod/staging runs that errored or timed out + * `fp sessions --agent-id agent-orderbot --since 24h` — every session agent-orderbot took part in over the last 24h + * `fp sessions --agents` — expand each multi-agent session to its full agent roster + * `fp --json sessions --all --fields session_id,agents | jq '.sessions'` — every session's full agent roster as JSON + """ + state: AppState = ctx.obj + cctx = require_auth(state) + validate_limit(limit) + frm, to = resolve_dates(since, ts_from, ts_to) + cols = resolve_fields(fields, Session) + + # Multi-value filters: merge repeated flags + comma-separated values into one flat, + # de-duplicated list each (the server UNIONs within a filter via `IN`, ANDs across them). + environment = collect_multi(environment) + status = collect_multi(status) + agent_id = collect_multi(agent_id) + session_id = collect_multi(session_id) + # Validate each status value client-side (clean exit 2) rather than a server 400. + for s in status or []: + validate_choice(s, ("done", "error", "timeout"), flag="--status") + + def fetch(cursor: Optional[str], limit: Optional[int]): + return api.list_sessions( + cctx, + session_id=session_id, + agent_id=agent_id, + environment=environment, + status=status, + ts_from=frm, + ts_to=to, + cursor=cursor, + limit=limit, + ) + + if fetch_all: + items = list(api.paginate(fetch, limit=limit, page_size=page_size, start_cursor=cursor)) + next_cursor = None + else: + page = fetch(cursor, limit) + items = page.items + next_cursor = page.next_cursor + + if state.json: + payload = output.project_dicts(items, cols) if cols else items + output.emit_json({"sessions": payload, "next_cursor": next_cursor}) + return + + # Which narrowing filters did the user set? Used to word the empty-box message and to + # nudge them to re-check those values when 0 rows come back (a value matching nothing + # returns empty, not an error — so a typo looks like "no sessions"). + active_filters = [ + flag for flag, val in ( + ("--env", environment), + ("--status", status), + ("--agent-id", agent_id), + ("--session-id", session_id), + ("--since/--from/--to", since or ts_from or ts_to), + ) if val + ] + + if cols: + # `--fields` asks for specific columns → the generic table (no bespoke styling). + output.print_table(list(cols), output.project_rows(items, cols), title=f"Sessions ({len(items)})") + else: + empty_message = "no sessions match these filters" if active_filters else "no sessions" + if agents_expand: + output.render_sessions_expanded(items, full_ids=full_ids, empty_message=empty_message) + else: + output.render_sessions(items, full_ids=full_ids, empty_message=empty_message) + multi_agent = sum(1 for e in items if output.is_multi_agent(e)) + output.sessions_footer(len(items), more=next_cursor is not None, multi_agent=multi_agent) + if not items: + output.recheck_filters_hint(active_filters) + + +def register(app: typer.Typer) -> None: + app.command("sessions", epilog=GLOBALS_EPILOG)(sessions) diff --git a/fp-cli/fp_cli/commands/settings_cmds.py b/fp-cli/fp_cli/commands/settings_cmds.py new file mode 100644 index 000000000..9d3ac881e --- /dev/null +++ b/fp-cli/fp_cli/commands/settings_cmds.py @@ -0,0 +1,150 @@ +"""Org settings: settings list / schema / set. + +Settings are a FIXED registry — you can read them (``list``), inspect what each accepts +(``schema``), and change an existing key's value (``set``). You can't create new keys. +""" + +from __future__ import annotations + +import json +from typing import Any, Optional + +import typer + +from .. import client as api +from .. import output +from .._context import GLOBALS_EPILOG, AppState, require_auth +from ..errors import NotFoundError +from . import _write + + +def settings_list(ctx: typer.Context) -> None: + """List the org's settings and their current values in a boxed table. + + Shows `key · value · type · updated` — the value rendered type-aware (lists comma-joined, + numbers pink, secrets masked), truncated to one line (full values in `--json`). `key` is the + handle `settings set` takes. Needs `settings:read`. With `--json`: `{"settings": [{key, value, + updated_at, updated_by, scope, schema}]}`. + + Example: + + * `fp settings list` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + rows = api.list_settings(cctx) + if state.json: + output.emit_json({"settings": rows}) + return + output.render_settings(rows, current_email=state.config.email) + + +def settings_schema(ctx: typer.Context) -> None: + """Show the settings registry — what each key is and what it accepts. + + A boxed `key · type · accepts · description` table derived from each setting's `schema` blob + (there is no separate schema endpoint). `accepts` summarizes the constraints (int range + unit, + channel options, …). Needs `settings:read`. With `--json`: `{"settings": [<schema entries>]}`. + + Example: + + * `fp settings schema` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + schema = api.get_settings_schema(cctx) + if state.json: + output.emit_json({"settings": schema}) + return + output.render_settings_schema(schema) + + +def settings_set( + ctx: typer.Context, + key: str = typer.Argument(..., help="Setting key to update (must be an existing key — see `settings list`)."), + value: Optional[str] = typer.Option(None, "--value", help="Scalar value (string; a digit-only value is sent as an integer)."), + json_value: Optional[str] = typer.Option(None, "--json-value", help="Raw JSON value, for arrays/objects (e.g. `[\"a@b.com\"]`)."), + file: Optional[str] = typer.Option(None, "--file", help="Read a JSON value from a file, or `-` for stdin."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Change one org setting's value, showing the before → after. + + Provide the value exactly one way: `--value` (scalar), `--json-value` (raw JSON for + arrays/objects), or `--file`/stdin (JSON). The CLI checks the key exists, shows the change and + confirms (a no-op is skipped), then renders the updated value. Needs `settings:write`. Unknown + key → exit 6; an invalid value → the server's clean message, exit non-zero. With `--json`: the + updated `SettingRow` (or `{cancelled: true}`). + + Examples: + + * `fp settings set session_ttl_secs --value 86400` + * `fp settings set alerts.email_default_recipients --json-value '["a@b.com"]'` + """ + state: AppState = ctx.obj + provided = [v is not None for v in (value, json_value, file)] + if sum(provided) != 1: + raise typer.BadParameter("Provide exactly one of --value, --json-value, or --file.") + + if value is not None: + # `str.isdigit()` accepts characters int() rejects (superscripts, "--5"); just try the + # conversion and fall back to the string, so a non-int value is never an uncaught traceback. + try: + parsed: Any = int(value) + except ValueError: + parsed = value + elif json_value is not None: + try: + parsed = json.loads(json_value) + except json.JSONDecodeError as exc: + raise typer.BadParameter(f"--json-value is not valid JSON: {exc}", param_hint="--json-value") + else: + raw = _write.read_text_arg(file) + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise typer.BadParameter(f"--file is not valid JSON: {exc}", param_hint="--file") + + cctx = require_auth(state) + current = next((s for s in api.list_settings(cctx) if s.key == key), None) + if current is None: # settings are a fixed registry — an unknown key can't be created + raise NotFoundError( + f'no setting named "{key}"', hint="run `fp settings list` to see them" + ) + kind = (current.schema or {}).get("kind", "") if isinstance(current.schema, dict) else "" + + if parsed == current.value: # no-op — don't call the server or prompt (secrets always blank) + if state.json: + output.emit_json(current) + else: + output.setting_no_change(key, current.value, kind) + return + + if _write.should_prompt(state, yes): + if not output.confirm_setting_change(key, current.value, parsed, kind): + if state.json: + output.emit_json({"cancelled": True}) + else: + output.print_cancelled("nothing changed") + return + + # An invalid value (422) / unknown key (404) propagates as a typed error to the central + # chokepoint (JSON envelope under --json, red box otherwise). + row = api.put_setting(cctx, key, parsed) + _write.record_action("setting_updated", resource="setting", success=True) + if state.json: + output.emit_json(row) + return + output.render_setting_updated(row, kind) + + +def register(app: typer.Typer) -> None: + settings_app = typer.Typer( + no_args_is_help=True, + rich_markup_mode="markdown", + context_settings={"help_option_names": ["-h", "--help"]}, + help="View and update org settings (list / schema / set).", + ) + settings_app.command("list", epilog=GLOBALS_EPILOG)(settings_list) + settings_app.command("schema", epilog=GLOBALS_EPILOG)(settings_schema) + settings_app.command("set", epilog=GLOBALS_EPILOG)(settings_set) + app.add_typer(settings_app, name="settings") diff --git a/fp-cli/fp_cli/commands/usage_cmds.py b/fp-cli/fp_cli/commands/usage_cmds.py new file mode 100644 index 000000000..d3a946456 --- /dev/null +++ b/fp-cli/fp_cli/commands/usage_cmds.py @@ -0,0 +1,35 @@ +"""usage — show the active organization's current billing-window usage.""" + +from __future__ import annotations + +import typer + +from .. import client as api +from .. import output +from .._context import GLOBALS_EPILOG, AppState, require_auth + + +def usage(ctx: typer.Context) -> None: + """Show usage for the active org's current fixed 30-day metering window. + + Reports telemetry, evaluations, workspace objects, audits, API keys, and members. This is + read-only and does not apply or display limits. Needs `usage:read`. + + With `--json`, returns the dashboard response unchanged: `org_id`, `billing_anchor`, + `window`, `usage`, `calculated_at`, and `stale_after`. + + Examples: + + * `fp usage` + * `fp --json usage | jq '.usage.events_ingested'` + """ + state: AppState = ctx.obj + data = api.get_usage(require_auth(state)) + if state.json: + output.emit_json(data) + return + output.render_usage(data) + + +def register(app: typer.Typer) -> None: + app.command("usage", epilog=GLOBALS_EPILOG)(usage) diff --git a/fp-cli/fp_cli/commands/users_cmds.py b/fp-cli/fp_cli/commands/users_cmds.py new file mode 100644 index 000000000..92b5e1c05 --- /dev/null +++ b/fp-cli/fp_cli/commands/users_cmds.py @@ -0,0 +1,394 @@ +"""Org member management: users list / show / create / update / disable / enable. + +Members are referenced by their **email** (unique within the org) — not a raw id — across +``show`` / ``update`` / ``disable`` / ``enable`` (a UUID-shaped handle is also accepted). The +whole group converges on the shared boxed visual language: a boxed member list, identity cards, +the shared grouped permissions panel, and the shared confirm/cancel helpers. Effective grants are +``(set ∪ added) − removed``; ``--add`` / ``--remove`` use the compact ``slug:act.act`` token +format (the same parser as ``keys``). Protected members can't be edited/disabled. + +A member's role comes from a **permission set** that lives **per org on the server** (managed in +the dashboard): the built-in ``read-only`` / ``standard`` / ``admin`` plus any custom sets the org +defines. The CLI hardcodes only those three built-ins — and only to *preview* the resulting grants +locally; the ``--permission-set`` value is resolved + expanded server-side, so custom org sets work +too (but a custom set can't be previewed, so ``update`` confirms it generically). +""" + +from __future__ import annotations + +from typing import List, Optional + +import typer + +from .. import _click_compat as click # the Click Typer is running; see _click_compat +from .. import client as api +from .. import output, permissions, theme +from .._context import GLOBALS_EPILOG, AppState, require_auth +from ..errors import ForbiddenError +from . import _write + + +def _resolve_user_or_exit(state: AppState, users, handle: str): + """Resolve a member by **email** (primary), falling back to an exact id match, raising a + typed error the central chokepoint renders (JSON envelope under ``--json``, red box + otherwise): none → exit 6, several → exit 2.""" + return _write.resolve_one( + users, + handle, + kind="user", + ref="with email", + key="email", + list_cmd="users list", + # The server lowercases on create, so the address the caller typed is not the address + # stored. Without this, `fp users create Alice.Chen@Example.com` succeeds and every + # subsequent show/update/disable/enable on that same string reports "no user with + # email" — the member is reachable only via a lowercased form nothing told them about. + casefold=True, + ) + + +def _parse_user_tokens_or_exit(state: AppState, tokens) -> List[str]: + """Expand the compact ``slug:act1.act2`` ``--add`` / ``--remove`` tokens into a flat + assignable permission list (empty when no value was given). A malformed/unknown token → a clean + usage error (exit 2), before any read/write. Space-separated tokens (quoted) and repeated flags + both compose — e.g. ``--add "keys:create.regenerate users:create.update"`` or + ``--add keys:create.regenerate --add users:create.update``.""" + if not tokens: + return [] + try: + return permissions.parse_permission_tokens(tokens) + except permissions.PermissionTokenError as exc: + raise click.UsageError(str(exc)) + + +def users_list( + ctx: typer.Context, + active_only: bool = typer.Option(False, "--active-only", help="Show only enabled members (hide disabled ones)."), + show_id: bool = typer.Option(False, "--show-id", help="Prepend a short user-id column (the full id is always in --json)."), +) -> None: + """List org members in a boxed table — active members first, disabled ones dimmed at the bottom. + + Shows `email · access · permissions · joined · status` with a leading 🔒 marker on protected + members; status is derived from the disable state (`● active` / `○ disabled`), and `joined` + comes from each member's join date. The raw id is hidden by default (use `--show-id` for a + short id, or `--json` for the full id). Needs `users:read`. With `--json`: + `{"users": [{id, email, permissions, permission_set, disabled_at, is_protected, created_at, …}]}`. + + Examples: + + * `fp users list` + * `fp users list --active-only` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + users = api.list_users(cctx) + if active_only: + users = [u for u in users if not u.disabled_at] + users = sorted(users, key=lambda u: 1 if u.disabled_at else 0) # active first, disabled last + if state.json: + output.emit_json({"users": users}) + return + output.render_users(users, show_id=show_id) + output.users_footer(users) + + +def users_show( + ctx: typer.Context, + email: str = typer.Argument(..., metavar="EMAIL", help="User email (or id)."), +) -> None: + """Show one member's identity and full effective permissions — like `whoami` for someone else. + + Renders an identity card (email, role, status) then the shared grouped permissions panel with + **all** the member's grants (no truncation). Referenced by **email** (or a UUID-shaped id). + Needs `users:read`. With `--json`: the full member object. + + Example: + + * `fp users show dev@example.com` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + user = _resolve_user_or_exit(state, api.list_users(cctx), email) + if state.json: + output.emit_json(user) + return + output.render_user_show(user) + + +def _validate_permission_set_or_exit(state: AppState, cctx, set_name: Optional[str]) -> None: + """Reject an unknown ``--permission-set`` client-side (clean exit 2) instead of a raw server + 422 with a request-id. Valid = the org's sets ∪ the built-in presets (the server expands it). + Mirrors how ``keys create`` validates its set.""" + presets = set(permissions.PRESETS) - {"clear"} # `clear` is an internal preset, not assignable + if not set_name or set_name in presets: + return # no set, or a built-in preset (always valid) → no extra round-trip + valid = set(api.list_permission_sets(cctx)) | presets + if set_name not in valid: + raise click.UsageError( + f'unknown permission set "{set_name}". available: {", ".join(sorted(valid))}' + ) + + +def users_create( + ctx: typer.Context, + email: str = typer.Argument(..., metavar="EMAIL", help="Email of the member to invite (unique within the org)."), + permission_set: Optional[str] = typer.Option(None, "--permission-set", help="Role to start from — a permission set: `read-only`, `standard`, `admin`, or a custom set your org defines in the dashboard. Omit for no base role."), + add: Optional[List[str]] = typer.Option(None, "--add", help="Grant extra permissions on top of the set, as `slug:action.action` tokens (dotted actions expand: `keys:create.regenerate` → `keys:create`, `keys:regenerate`). Several via comma, repeated flag, or a quoted group: `--add keys:create,users:read` · `--add a --add b` · `--add \"a b\"`."), + remove: Optional[List[str]] = typer.Option(None, "--remove", help="Revoke permissions from the set, same `slug:action.action` token format as --add (comma / repeated / quoted)."), +) -> None: + """Invite an org member and show their new identity + permissions. + + Give the member's **email** (positional). Their role comes from `--permission-set` (a built-in + `read-only` / `standard` / `admin`, or a custom set your org defines in the dashboard), and you + can fine-tune it per member with `--add` / `--remove`. The effective grants are + `(set ∪ added) − removed`, expanded server-side; the permissions box shows the result. + + `--add` / `--remove` take the compact `slug:action.action` token format — dotted actions expand + (`keys:create.regenerate` → two grants) and several compose by comma, repeated flag, or a quoted + group. Needs `users:create`. With `--json`: `{id, email, permission_set, permissions}` + (`permissions` = the expanded set). + + Examples: + + * `fp users create dev@example.com --permission-set standard` — invite with the standard role + * `fp users create ci@example.com --permission-set read-only --add keys:create.regenerate` — read-only plus key management + * `fp users create lead@example.com --permission-set admin --remove settings:write` — admin minus settings writes + """ + state: AppState = ctx.obj + parsed_add = _parse_user_tokens_or_exit(state, add) + parsed_remove = _parse_user_tokens_or_exit(state, remove) + cctx = require_auth(state) + _validate_permission_set_or_exit(state, cctx, permission_set) + if any(u.email == email for u in api.list_users(cctx)): # emails are unique + raise click.UsageError(f'a user with email "{email}" already exists') + user = api.create_user( + cctx, email=email, permission_set=permission_set, + permission_added=parsed_add or None, permission_removed=parsed_remove or None, + ) + _write.record_action("user_created", resource="user", success=True, permission_count=len(user.permissions)) + if state.json: + output.emit_json({"id": user.id, "email": user.email, + "permission_set": user.permission_set, "permissions": user.permissions}) + return + output.render_user_created(user) + + +def users_update( + ctx: typer.Context, + email: str = typer.Argument(..., metavar="EMAIL", help="Email (or id) of the member to update."), + permission_set: Optional[str] = typer.Option(None, "--permission-set", help="Reassign the member's role — a permission set: `read-only`, `standard`, `admin`, or a custom set your org defines in the dashboard. Replaces their per-member overrides (apply fresh ones with --add/--remove)."), + add: Optional[List[str]] = typer.Option(None, "--add", help="Grant permissions, same `slug:action.action` token format as `users create` (dotted actions expand; comma / repeated flag / quoted group compose). Incremental — merged into the member's CURRENT grants, unless --permission-set is also given."), + remove: Optional[List[str]] = typer.Option(None, "--remove", help="Revoke permissions, same `slug:action.action` token format as --add. Incremental — applied to the member's CURRENT grants, unless --permission-set is also given."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Change a member's permissions and show the diff. Referenced by **email** (or a UUID id). + + Two ways to use it (combine if you like): + + * **Reassign a role** — `--permission-set <set>` swaps the member's base role (a built-in + `read-only` / `standard` / `admin`, or a custom org set); add `--add`/`--remove` to layer + fresh per-member overrides on top. + * **Tweak incrementally** — `--add` / `--remove` **alone** adjust the member's *current* grants + (their set is kept). `--add` grants, `--remove` revokes; both take the compact + `slug:action.action` token format (dotted actions expand; comma / repeated / quoted compose). + + The CLI reads the current grants, computes the resulting set `(set ∪ added) − removed`, and + shows a git-style diff — added green, removed struck-through, unchanged dim. It confirms first + (default no; `--yes` skips) and a no-op exits without calling the server. Needs `users:update` + (+ `users:read`). With `--json`: `{id, email, permission_set, permissions, added, removed}`. + + Examples: + + * `fp users update dev@example.com --add keys:create.regenerate` — grant on top of current grants + * `fp users update dev@example.com --remove alerts:read,incidents:ack` — revoke a couple of grants + * `fp users update dev@example.com --add events:read --remove keys:delete` — add and remove at once + * `fp users update dev@example.com --permission-set admin --yes` — reassign the role (no prompt) + """ + state: AppState = ctx.obj + if permission_set is None and not add and not remove: + raise typer.BadParameter("nothing to update — pass --permission-set, --add, and/or --remove.") + parsed_add = _parse_user_tokens_or_exit(state, add) + parsed_remove = _parse_user_tokens_or_exit(state, remove) + both = sorted(set(parsed_add) & set(parsed_remove)) + if both: + raise typer.BadParameter(f"{', '.join(both)} given to both --add and --remove.") + cctx = require_auth(state) + _validate_permission_set_or_exit(state, cctx, permission_set) + user = _resolve_user_or_exit(state, api.list_users(cctx), email) + before = set(user.permissions) + add_set, remove_set = set(parsed_add), set(parsed_remove) + + # Build the body to send + predict the resulting effective set (for the confirm preview). + if permission_set is None: + # Incremental: merge the override deltas into the member's CURRENT overrides (keep the set). + new_set = user.permission_set + merged_add = (set(user.permission_added or []) | add_set) - remove_set + merged_remove = (set(user.permission_removed or []) | remove_set) - add_set + new_added, new_removed = sorted(merged_add), sorted(merged_remove) + # Applying the deltas to the current effective set is exact (base is unchanged). + predicted_after = (before | add_set) - remove_set + else: + # Assign a role: the set is the base; the flags are its fresh overrides. + new_set, new_added, new_removed = permission_set, sorted(add_set), sorted(remove_set) + if permission_set in permissions.PRESETS: + predicted_after = (set(permissions.PRESETS[permission_set]) | add_set) - remove_set + else: + predicted_after = None # a custom set we can't expand locally → confirm generically + + if predicted_after is not None: + p_added = predicted_after - before + p_removed = before - predicted_after + if not p_added and not p_removed: # no-op: don't call the server, don't prompt + if state.json: + output.emit_json({"id": user.id, "email": user.email, "permission_set": new_set, + "permissions": sorted(before), "added": [], "removed": []}) + else: + output.user_no_change() + return + proceed = (not _write.should_prompt(state, yes)) or output.confirm_user_update( + user.email, len(p_added), len(p_removed)) + else: + proceed = _write.confirm_action( + state, "change permissions for", user.email, + consequence="this reassigns their role; the user keeps access", assume_yes=yes) + + if not proceed: + if state.json: + output.emit_json({"cancelled": True}) + else: + output.print_cancelled("permissions unchanged") + return + + result = api.update_user( + cctx, user.id, permission_set=new_set, permission_added=new_added, permission_removed=new_removed) + after = set(result.permissions) + added, removed = sorted(after - before), sorted(before - after) + _write.record_action("user_updated", resource="user", success=True, permission_count=len(after)) + if state.json: + output.emit_json({"id": result.id, "email": result.email, "permission_set": result.permission_set, + "permissions": sorted(after), "added": added, "removed": removed}) + return + if not added and not removed: # custom-set assign that turned out to be a no-op + output.user_no_change() + return + output.render_user_updated(result, added=added, removed=removed, union=sorted(before | after)) + + +def users_disable( + ctx: typer.Context, + email: str = typer.Argument(..., metavar="EMAIL", help="User email (or id) to disable."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Disable a member by email — they can no longer sign in (reversible with `users enable`). + + Confirms first (amber, default no; `--yes` skips). Refuses a protected member or your own + account. Already-disabled is a calm no-op. Needs `users:delete`. With `--json`: + `{id, email, status: "disabled"}` (or `{cancelled: true}` on a declined prompt). + + Example: + + * `fp users disable dev@example.com` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + user = _resolve_user_or_exit(state, api.list_users(cctx), email) + if user.is_protected: + raise ForbiddenError(f'"{user.email}" is protected and can\'t be disabled') + if state.config.email and user.email == state.config.email: + raise ForbiddenError("you can't disable your own account") + if user.disabled_at: # already disabled — a no-op, not an error + if state.json: + output.emit_json({"id": user.id, "email": user.email, "status": "disabled"}) + else: + output.user_already_disabled(user.email) + return + if not _write.confirm_action( + state, "disable user", user.email, + consequence="they can no longer sign in — you can re-enable them later", assume_yes=yes, + ): + if state.json: + output.emit_json({"cancelled": True}) + else: + output.print_cancelled("nothing changed") + return + api.disable_user(cctx, user.id) + _write.record_action("user_disabled", resource="user", success=True, destructive=True) + if state.json: + output.emit_json({"id": user.id, "email": user.email, "status": "disabled"}) + else: + output.user_disabled(user.email) + + +def users_enable( + ctx: typer.Context, + email: str = typer.Argument(..., metavar="EMAIL", help="User email (or id) to re-enable."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Re-enable a disabled member by email — they can sign in again. + + Confirms first (a calm re-activation prompt, default no; `--yes` skips). Already-active is a + calm no-op. Needs `users:delete`. With `--json`: `{id, email, status: "active"}` (or + `{cancelled: true}` on a declined prompt). + + Example: + + * `fp users enable dev@example.com` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + user = _resolve_user_or_exit(state, api.list_users(cctx), email) + if not user.disabled_at: # already active — a no-op, not an error + if state.json: + output.emit_json({"id": user.id, "email": user.email, "status": "active"}) + else: + output.user_already_active(user.email) + return + if not _write.confirm_action( + state, "re-enable user", user.email, + consequence="they'll be able to sign in again", assume_yes=yes, + glyph="↑", color=theme.ACCENT, + ): + if state.json: + output.emit_json({"cancelled": True}) + else: + output.print_cancelled("nothing changed") + return + result = api.enable_user(cctx, user.id) + _write.record_action("user_enabled", resource="user", success=True) + if state.json: + output.emit_json({"id": result.id, "email": result.email, "status": "active"}) + else: + output.user_enabled(user.email) + + +_USERS_GROUP_HELP = """Manage org members — list, inspect, invite, and adjust their permissions. + +Members are referenced by **email** (unique in the org). A member's grants are a permission +set (role) plus optional per-member `--add` / `--remove` overrides. + +**Subcommands:** `list` · `show` · `create` · `update` · `disable` · `enable` + +**Examples:** + +* `fp users list --active-only` — current members, active first +* `fp users show dev@example.com` — one member's identity + full grants +* `fp users create dev@example.com --permission-set standard` — invite with a role +* `fp users update dev@example.com --add keys:create --remove alerts:read` — tweak grants +* `fp users disable dev@example.com` / `fp users enable dev@example.com` — revoke / restore sign-in +""" + + +def register(app: typer.Typer) -> None: + users_app = typer.Typer( + no_args_is_help=True, + rich_markup_mode="markdown", + context_settings={"help_option_names": ["-h", "--help"]}, + help=_USERS_GROUP_HELP, + ) + users_app.command("list", epilog=GLOBALS_EPILOG)(users_list) + users_app.command("show", epilog=GLOBALS_EPILOG)(users_show) + users_app.command("create", epilog=GLOBALS_EPILOG)(users_create) + users_app.command("update", epilog=GLOBALS_EPILOG)(users_update) + users_app.command("disable", epilog=GLOBALS_EPILOG)(users_disable) + users_app.command("enable", epilog=GLOBALS_EPILOG)(users_enable) + app.add_typer(users_app, name="users") diff --git a/fp-cli/fp_cli/config.py b/fp-cli/fp_cli/config.py new file mode 100644 index 000000000..7e4cb37bc --- /dev/null +++ b/fp-cli/fp_cli/config.py @@ -0,0 +1,330 @@ +"""Persistent CLI configuration at ``~/.failproofai/fpcli/cli-auth.json`` (mode 0600). + +This used to be ``~/.fp/cli.json`` — a third top-level dotfile beside +``~/.failproofai`` (the Enforcement CLI) and ``~/.agenteye`` (the SDK and +collector's event spool). One product owning three home directories is one more +than anybody can keep track of, so the CLI moved under the failproofai home. +``~/.agenteye`` stays where it is: it is a wire contract with the collector, not +a preference (see the SDK's ``test_server_contract.py``). + +The failproofai home is a governed layout, NOT a free directory. Its shape is +declared in one place — ``src/hooks/fp-home.ts`` in this repo, mirrored for the +daemon in ``crates/failproofaid/src/paths.rs`` — and that file's rule is that +nothing outside it may join a path onto the home. So ``fpcli/cli-auth.json`` is +registered there too, classified ``user-typed`` in ``HOME_CLASSES``. That +classification is what keeps it: a layout migration deletes only ``derived`` and +``refetchable`` paths, and ``resettablePaths()`` is a filter over that table +rather than a hand-written list. It follows ``audit/session.json``, which is the +same thing for the audit tool and is likewise TS-side only — the daemon never +opens a human credential. + +We only ever create. ``mkdir(parents=True, exist_ok=True)`` will bring +``~/.failproofai`` into existence on a machine that has never run the +Enforcement CLI, and leaves a populated one exactly as it found it. Nothing here +removes or rewrites a path it does not own. + +The move is invisible to the user: a session at the old path is adopted on the +next command, so nobody is signed out by an upgrade. The old file is copied, not +moved, which keeps a downgrade working — an older `fp` still finds its session +where it left it. +""" + +from __future__ import annotations + +import errno +import json +import os +import stat +import tempfile +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Optional + +# The dashboard the CLI talks to when nothing else says otherwise. Resolution is +# always explicit flag/env (`--base-url` / `FP_DASHBOARD_URL`) > saved config +# (`~/.failproofai/fpcli/cli-auth.json`) > this default, so a fresh install points at the hosted +# product with zero configuration, while a self-hosted or dev user overrides it +# once (at login, or per command) and never thinks about it again. +# NOT stored in `CliConfig` — a saved config with no `base_url` still reads back +# as `None`; the default is applied only when resolving the effective URL. +DEFAULT_BASE_URL = "https://app.befailproof.ai" + + +#: The CLI's own directory inside the failproofai home. Mirrors ``fpcliDir`` in +#: ``src/hooks/fp-home.ts``. Something checks now: +#: ``tests/test_fp_home_contract.py`` reads that file and fails if the two names +#: drift. Before it existed, renaming one side left 53 TS tests and 59 Python +#: tests passing while the credential sat at a path the layout register had +#: never heard of. +FPCLI_SUBDIR = "fpcli" + +#: The pre-move location. READ once, to adopt a session that would otherwise be +#: lost, and never written or deleted — see :func:`load_config`, which copies it +#: to the new path and leaves the original so a downgrade still finds it. +#: +#: This said "never read" until it was checked against the code. Adoption +#: arrived after the move and the comment did not follow it. +LEGACY_DIR_NAME = ".fp" +LEGACY_FILE_NAME = "cli.json" + + +def base_dir() -> Path: + """Where ``cli-auth.json`` lives. + + ``$FP_HOME`` (the CLI's own variable, and the one the docs have always + named) wins, so an existing override keeps working untouched. Otherwise + ``$FAILPROOFAI_HOME`` — the variable the rest of the failproofai home + already honours, which containers and tests routinely set — and finally + ``~/.failproofai/fpcli``. + + Both env vars name a DIRECTORY that the config file sits directly in. + ``FAILPROOFAI_HOME`` points at the home root, so the subdirectory is + appended; ``FP_HOME`` points at the CLI's own directory and is used as-is, + which is what it meant before the move. + """ + override = os.environ.get("FP_HOME") + if override: + return Path(override) + fp_home = os.environ.get("FAILPROOFAI_HOME") + if fp_home: + return Path(fp_home) / FPCLI_SUBDIR + return Path.home() / ".failproofai" / FPCLI_SUBDIR + + +def config_path() -> Path: + return base_dir() / "cli-auth.json" + + +def legacy_config_paths() -> list[Path]: + """Every place a pre-move session could still be sitting. + + Two, not one, and the second is the one that is easy to miss. The move + changed the FILENAME as well as the directory, so somebody who exported + ``FP_HOME`` — the documented way to relocate this config, and the shape CI + images use — has their old session at ``$FP_HOME/cli.json`` and will never + own a ``~/.fp`` at all. Checking only the default would hand exactly those + users an unexplained logout, which is the group least able to shrug at one. + + A session found here is COPIED to the new location on the next command and + the original is left alone — see :func:`load_config`. Copying rather than + moving keeps a downgrade working: an older `fp` still finds its session. + + The relocated path is checked FIRST. When both exist, the one in the + directory this invocation actually resolved is the one that explains this + user's logout; naming the default instead sends somebody with ``FP_HOME`` + set to delete an unrelated file on a machine they may share. + """ + candidates = [base_dir() / LEGACY_FILE_NAME] + # `~/.fp` is only a candidate when the user has NOT redirected the config. + # Someone who exported `FP_HOME` said where their config lives; reaching past + # that into the home directory would adopt a session from a different context + # — a different tenant, or another user's leftovers on a shared box — and is + # also how this fallback quietly picked up the developer's own login when the + # suite ran. + if not os.environ.get("FP_HOME"): + default = Path.home() / LEGACY_DIR_NAME / LEGACY_FILE_NAME + if default not in candidates: + candidates.append(default) + return candidates + + +def legacy_config_path() -> Optional[Path]: + """The first pre-move session file that actually exists, if any.""" + for path in legacy_config_paths(): + try: + if path.is_file(): + return path + except OSError: + continue + return None + + +def legacy_install_detected() -> bool: + """True when a pre-move session is on disk and the new one is not. + + False once the new config exists, so the notice stops after the first + successful `fp login` rather than nagging forever. + """ + try: + if config_path().is_file(): + return False + except OSError: + return False + return legacy_config_path() is not None + + +@dataclass +class CliConfig: + base_url: Optional[str] = None + session_token: Optional[str] = None + expires_at: Optional[str] = None # ISO 8601, e.g. 2026-05-26T12:00:00Z + email: Optional[str] = None + user_id: Optional[str] = None + insecure: bool = False + org: Optional[str] = None # active tenant slug, chosen at login (multi-tenant) + anonymous_id: Optional[str] = None # stable per-machine id for anonymous telemetry + + +def _parse(path: Path) -> Optional[CliConfig]: + """Read one config file, or ``None`` if it is missing/unreadable/not ours.""" + try: + data = json.loads(path.read_text()) + except (FileNotFoundError, json.JSONDecodeError, OSError, UnicodeDecodeError): + return None + if not isinstance(data, dict): + return None + return _from_dict(data) + + +def load_config() -> CliConfig: + """Load the session, adopting a pre-move one the first time we see it. + + The move is silent for the user: nobody is signed out, no command changes + behaviour, and CI that authenticates by env var never enters this path at + all. The adoption is a copy — the old file is left exactly where it is, so + downgrading to a previous `fp` finds its session intact and this is + reversible on the machine as well as in the release. + + Adoption is best-effort by design. If the new location cannot be written + (read-only home, a symlink we refuse, a full disk) the caller still gets the + session that was found, so a machine that cannot be migrated keeps working + rather than being logged out by our own housekeeping. + """ + current = _parse(config_path()) + if current is not None: + return current + + for legacy in legacy_config_paths(): + adopted = _parse(legacy) + if adopted is None: + continue + try: + save_config(adopted) + except OSError: + pass # unwritable target: still hand back the session we found + return adopted + + return CliConfig() + + +def _from_dict(data: dict) -> CliConfig: + return CliConfig( + base_url=data.get("base_url"), + session_token=data.get("session_token"), + expires_at=data.get("expires_at"), + email=data.get("email"), + user_id=data.get("user_id"), + insecure=bool(data.get("insecure", False)), + org=data.get("org"), + anonymous_id=data.get("anonymous_id"), + ) + + +def save_config(cfg: CliConfig) -> None: + """Write the session atomically, owner-only, without following any link. + + Both halves of this matter only because the file now sits in a directory + another product's secrets live in. Writing in place was fine when the CLI + owned ``~/.fp`` outright; beside ``credentials.json`` it is a way to destroy + someone else's token. + + * A **symlink** at ``cli-auth.json`` is refused outright. It is not removed: + a link is something a person put there, and replacing it silently is the + behaviour this function exists to avoid. + * A **hard link** cannot be detected the same way — it is not a link, it is a + second name for one inode, so ``O_NOFOLLOW`` says nothing about it. Writing + to a temporary file and ``os.replace``-ing it into position is what + actually answers this: rename swaps the DIRECTORY ENTRY, so the other name + keeps the old inode and the neighbour's file is untouched. + + The same rename gives two things worth having on their own: a reader never + observes a half-written credential, and two ``fp`` processes racing end with + one of the two sessions rather than a splice of both. + """ + path = config_path() + # `mode` applies to the LEAF only, which is exactly the split we want: our + # own directory is created `0700` because it holds a credential, while a + # `~/.failproofai` we happen to be the first to create is left to the user's + # umask — the same shape the Enforcement CLI would have made it. An existing + # directory keeps its mode either way: `exist_ok=True` does not chmod, and + # re-permissioning a home another product owns is not ours to do. + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + + # `lstat`, not `exists`: the question is what the NAME is, not what it leads + # to. A dangling link must be refused too, or the refusal depends on whether + # the attacker's target happens to exist yet. + try: + if stat.S_ISLNK(os.lstat(path).st_mode): + raise OSError( + errno.ELOOP, + f"{path} is a symbolic link. Refusing to write the session " + "through it — that would overwrite whatever it points at, and " + "this directory is shared with the Enforcement CLI. Remove the " + "link and run the command again.", + ) + except FileNotFoundError: + pass # the ordinary first-login case + + payload = json.dumps(asdict(cfg), indent=2) + "\n" + # Same directory, so the rename is on one filesystem and therefore atomic. + # + # `mkstemp` rather than a name built from the pid: two THREADS share a pid, + # so a pid-derived name collided under `O_EXCL` and turned a concurrent + # `save_config` into a crash. It also creates `0600` and `O_EXCL` itself, + # which is the same guarantee hand-rolled with fewer ways to get it wrong. + fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp") + tmp = Path(tmp_name) + try: + with os.fdopen(fd, "w") as fh: + fh.write(payload) + fh.flush() + os.fsync(fh.fileno()) # survive a crash between write and rename + os.chmod(tmp, 0o600) + os.replace(tmp, path) + except BaseException: + # Never leave a stray credential behind on the failure paths. + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def clear_token(cfg: CliConfig) -> CliConfig: + """Clear the whole logged-in session and persist. + + Drops everything tied to *who* was signed in — token, expiry, email, user id, + and the active org/tenant — so a logout leaves no stale identity (and the next + `login` starts the org picker fresh, with no remembered tenant). Kept on + purpose: `base_url` and the `insecure` TLS preference (so the next login + doesn't need them re-specified) and the machine-stable `anonymous_id`. + """ + cfg.session_token = None + cfg.expires_at = None + cfg.email = None + cfg.user_id = None + cfg.org = None + save_config(cfg) + return cfg + + +def _parse_iso(value: str) -> datetime: + # Python 3.10's fromisoformat does not accept a trailing 'Z'. + normalized = value.strip().replace("Z", "+00:00") + dt = datetime.fromisoformat(normalized) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def is_expired(cfg: CliConfig, *, skew_secs: int = 60, now: Optional[datetime] = None) -> bool: + """True if there is no usable, unexpired token (with a safety skew).""" + if not cfg.session_token or not cfg.expires_at: + return True + try: + expires = _parse_iso(cfg.expires_at) + except ValueError: + return True + now = now or datetime.now(timezone.utc) + return now >= expires - timedelta(seconds=skew_secs) diff --git a/fp-cli/fp_cli/dates.py b/fp-cli/fp_cli/dates.py new file mode 100644 index 000000000..f8b3908d9 --- /dev/null +++ b/fp-cli/fp_cli/dates.py @@ -0,0 +1,78 @@ +"""Relative date-range presets, mirroring the dashboard's ``resolveDateRange``. + +``--since`` accepts one of ``15m|1h|6h|24h|7d|all`` and is converted to a +``ts_from`` lower bound (open-ended to "now"). ``--from``/``--to`` provide an +explicit custom range and take precedence over ``--since``. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Optional, Tuple + +PRESETS = { + "15m": timedelta(minutes=15), + "1h": timedelta(hours=1), + "6h": timedelta(hours=6), + "24h": timedelta(hours=24), + "7d": timedelta(days=7), +} + +# Accepted values for --since (including the no-op "all"). +SINCE_CHOICES = ["all", "15m", "1h", "6h", "24h", "7d"] + + +def _iso(dt: datetime) -> str: + return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _validate_iso(value: str, flag: str) -> str: + """Validate an explicit ``--from``/``--to`` is a full RFC3339 UTC datetime. + + The server deserializes ``ts_from``/``ts_to`` as ``chrono::DateTime<Utc>``, which + requires a ``T`` separator AND an explicit timezone (``Z`` or ``±HH:MM``). A + date-only, timezone-less (``2026-05-01T00:00:00``), or space-separated value is + sent verbatim and rejected by the server with a 400; validating here turns that + into a clean usage error (exit 2) instead. + """ + v = value.strip() + try: + parsed = datetime.fromisoformat(v.replace("Z", "+00:00")) + except ValueError: + parsed = None + if parsed is None or "T" not in v or parsed.tzinfo is None: + raise ValueError( + f"invalid {flag} value {value!r}; expected an ISO-8601 UTC timestamp, " + "e.g. 2026-05-01T00:00:00Z" + ) + return v + + +def resolve_range( + since: Optional[str] = None, + ts_from: Optional[str] = None, + ts_to: Optional[str] = None, + *, + now: Optional[datetime] = None, +) -> Tuple[Optional[str], Optional[str]]: + """Return ``(ts_from, ts_to)`` for the given inputs. + + Explicit ``ts_from``/``ts_to`` win. Otherwise a ``--since`` preset sets the + lower bound. ``since="all"`` (or ``None``) means no bounds. + """ + if ts_from is not None or ts_to is not None: + return ( + _validate_iso(ts_from, "--from") if ts_from is not None else None, + _validate_iso(ts_to, "--to") if ts_to is not None else None, + ) + + if since is None or since == "all": + return None, None + + if since not in PRESETS: + raise ValueError( + f"invalid --since value {since!r}; choose one of {', '.join(SINCE_CHOICES)}" + ) + + now = now or datetime.now(timezone.utc) + return _iso(now - PRESETS[since]), None diff --git a/fp-cli/fp_cli/enforcement.py b/fp-cli/fp_cli/enforcement.py new file mode 100644 index 000000000..44a89fd85 --- /dev/null +++ b/fp-cli/fp_cli/enforcement.py @@ -0,0 +1,345 @@ +"""The logic behind `fp policies` and `fp fleet`, with no HTTP in it. + +Everything here is pure so it can be tested without a server, because the two +things most likely to lose someone's work are decided here rather than in a +handler: what a deploy's resulting policy set is, and whether somebody else +wrote while we were deciding. + +## Why a diff at all + +`PUT /enforcement/deployments/{id}` is a FULL REPLACE. Send `{"policies": [a]}` +to a machine running `[a, b, c]` and it now runs `[a]` — permanently, with a +200 and no warning. The dashboard never exposes that as a form for exactly this +reason (`app/(dashboard)/[org]/enforcement/page.tsx`: "a form that asks you to +re-pick a machine and re-tick its policies silently drops whatever you forget +to tick"). It edits the machine's own current set instead. + +So `--add`/`--remove` are the CLI's equivalent: read the current set, apply the +delta, write the whole thing back. `--set` remains for the declarative case, +and is the only way to express "exactly these, drop the rest". + +## Why the race check + +There is no optimistic locking on that endpoint. The dashboard detects a +collision AFTER the fact by checking the returned generation is exactly +`base + 1` (`lib/enforcementFleet.ts`, `staleness()`). The same check here is +what stops two operators silently overwriting each other — the CLI refuses and +re-reads rather than reporting a success that erased somebody. +""" +from __future__ import annotations + +import re +import sys +from dataclasses import dataclass +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + +from .errors import ApiError +from .models import PolicyRef, PolicyVersion + +VALID_EFFECTS = ("enforce", "observe") + +#: `id`, `id@3`, `id:observe`, `id@3:observe`. The id charset mirrors the +#: server's `safe_identifier`, so a ref this accepts is one the server will too +#: — a rejection should come from the policy not existing, not from parsing. +_REF = re.compile(r"^(?P<id>[A-Za-z0-9._-]{1,128})(?:@(?P<version>\d+))?(?:[:](?P<effect>[a-z]+))?$") + + +class RefError(ValueError): + """A malformed `--add` / `--remove` / `--set` token, with the reason.""" + + +class RefUsageError(RefError): + """A RefError the caller can fix by retyping the command. + + Split out so the command layer can exit 2 (usage) rather than 1 (API error) + for these, which is what the documented exit-code table promises and what + `--since` and `--expect` in these same commands already do. A malformed + token, two flags that contradict each other, or a path that is not readable + text are all "you typed it wrong" — not "the server said no". + + Subclasses ``RefError`` so every existing caller and test that catches the + base class keeps working unchanged. + """ + + +def parse_ref(token: str) -> Tuple[str, Optional[int], Optional[str]]: + """``"id@2:observe"`` → ``("id", 2, "observe")``; omitted parts are None. + + Version and effect are resolved later — ``None`` means "whatever is current", + which is not the same as a default, because for an existing deployment the + current value is the deployed one rather than the newest one. + """ + token = token.strip() + if not token: + raise RefUsageError("empty policy reference") + m = _REF.match(token) + if not m: + raise RefUsageError( + f"{token!r} is not a policy reference — expected id, id@version, " + "id:effect or id@version:effect" + ) + effect = m.group("effect") + if effect is not None and effect not in VALID_EFFECTS: + raise RefUsageError( + f"{token!r} has effect {effect!r}; expected one of {', '.join(VALID_EFFECTS)}" + ) + version = m.group("version") + return m.group("id"), (int(version) if version is not None else None), effect + + +@dataclass +class DeployPlan: + """The resulting set, and how it differs from what the machine runs now. + + `result` is what will be PUT — the whole set, because that is what the + endpoint takes. The three lists exist to be shown to a human before it is. + """ + + machine_id: str + base: Optional[int] + result: List[PolicyRef] + added: List[PolicyRef] + removed: List[PolicyRef] + changed: List[Tuple[PolicyRef, PolicyRef]] + unchanged: List[PolicyRef] + + @property + def is_noop(self) -> bool: + return not (self.added or self.removed or self.changed) + + def to_dict(self) -> Dict[str, object]: + return { + "machineId": self.machine_id, + "base": self.base, + "result": [p.to_dict() for p in self.result], + "added": [p.to_dict() for p in self.added], + "removed": [p.to_dict() for p in self.removed], + "changed": [{"from": a.to_dict(), "to": b.to_dict()} for a, b in self.changed], + "unchanged": [p.to_dict() for p in self.unchanged], + "noop": self.is_noop, + } + + +def latest_versions(policies: Iterable[PolicyVersion]) -> Dict[str, int]: + """`{policy_id: newest published version}`, ignoring archived policies.""" + out: Dict[str, int] = {} + for p in policies: + if p.archived: + continue + if p.version > out.get(p.id, 0): + out[p.id] = p.version + return out + + +def disabled_ids(policies: Iterable[PolicyVersion]) -> set: + """Policies the server will refuse to deploy. + + The server rejects these anyway, but only after the CLI has drawn a plan and + asked the operator to confirm it — so the last thing on screen is a change + that cannot happen, under a prompt that implied it could. Everything else + the plan depends on (the machine exists, the policy exists) is already + checked before the plan is built; this was the one gap. + """ + return {p.id for p in policies if p.disabled and not p.archived} + + +def resolve_ref( + token: str, + *, + latest: Dict[str, int], + current: Dict[str, PolicyRef], + disabled: Optional[set] = None, +) -> PolicyRef: + """Turn one `--add`/`--set` token into a concrete `PolicyRef`. + + Version: explicit wins; else the version already deployed (so `--add` on a + policy the machine already runs is a no-op rather than a silent upgrade); + else the newest published. + + Effect: explicit wins; else the deployed effect; else `enforce`, matching + the server's own default for an omitted effect. + """ + pid, version, effect = parse_ref(token) + if disabled and pid in disabled and pid not in current: + raise RefError( + f"{pid!r} is disabled — `fp policies enable {pid}` first, or the machine " + "would be sent a deployment the server refuses" + ) + existing = current.get(pid) + if version is None: + version = existing.version if existing else latest.get(pid) + if version is None: + raise RefError( + f"no published policy named {pid!r} — run `fp policies list` to see what exists" + ) + if effect is None: + effect = existing.effect if existing else "enforce" + return PolicyRef(id=pid, version=version, effect=effect) + + +def plan_deploy( + machine_id: str, + *, + current: Optional[Sequence[PolicyRef]], + base: Optional[int], + add: Sequence[str] = (), + remove: Sequence[str] = (), + replace: Optional[Sequence[str]] = None, + latest: Optional[Dict[str, int]] = None, + disabled: Optional[set] = None, +) -> DeployPlan: + """Compute the full resulting set, plus the diff to show before writing. + + `replace` (`--set`) is exclusive with `add`/`remove`: mixing "these exactly" + with "these as well" has no single obvious reading, and guessing one would + be guessing about somebody's fleet. + """ + latest = latest or {} + current_list = list(current or []) + current_map = {p.id: p for p in current_list} + + if replace is not None: + if add or remove: + raise RefUsageError( + "--set replaces the whole set; it cannot be combined with --add/--remove" + ) + result_map = {} + for token in replace: + ref = resolve_ref(token, latest=latest, current=current_map, disabled=disabled) + result_map[ref.id] = ref + else: + result_map = dict(current_map) + for token in remove: + pid, _, _ = parse_ref(token) + if pid not in result_map: + raise RefError( + f"{pid!r} is not deployed to {machine_id} — nothing to remove" + ) + del result_map[pid] + for token in add: + ref = resolve_ref(token, latest=latest, current=current_map, disabled=disabled) + result_map[ref.id] = ref + + result = sorted(result_map.values(), key=lambda p: p.id) + added, removed, changed, unchanged = [], [], [], [] + for pid, ref in sorted(result_map.items()): + was = current_map.get(pid) + if was is None: + added.append(ref) + elif (was.version, was.effect) != (ref.version, ref.effect): + changed.append((was, ref)) + else: + unchanged.append(ref) + for pid, was in sorted(current_map.items()): + if pid not in result_map: + removed.append(was) + + return DeployPlan( + machine_id=machine_id, + base=base, + result=result, + added=added, + removed=removed, + changed=changed, + unchanged=unchanged, + ) + + +def check_race(base: Optional[int], returned: int) -> None: + """Raise when a deploy landed on top of somebody else's. + + `base` is the generation read before the write. A clean write is exactly + `base + 1`; anything else means another writer got in between, and their + change is already gone — a full replace does not merge. Reporting success + here is how the CLI would become the easiest way to silently overwrite a + colleague. + """ + if base is None: + return + if returned != base + 1: + raise ApiError( + f"deployment {returned} landed where {base + 1} was expected — someone " + "else deployed to this machine while this command was deciding, and a " + "deploy REPLACES the whole set rather than merging.", + hint="re-run `fp fleet show <machine>` to see the current set, then deploy again", + ) + + +def read_source( + value: Optional[str], + *, + stdin=None, + isatty: Optional[bool] = None, + prompt=None, +) -> str: + """Resolve policy source from a path, `@path`, `-`, a pipe, or a paste. + + The five shapes exist because the thing being supplied is a file that people + have in five different places: on disk, in a pipeline, in a heredoc, or on + the clipboard. Refusing the clipboard would mean "save it to a file first" + for the most common one-off case. + + A bare `-` and a piped stdin are the same read; the difference is only + whether the user said so. On a TTY with nothing given we prompt, because + silently blocking on stdin is indistinguishable from a hang. + """ + stream = sys.stdin if stdin is None else stdin + tty = stream.isatty() if isatty is None else isatty + + if value == "-": + return _checked(_read_stream(stream)) + if value: + path = value[1:] if value.startswith("@") else value + try: + with open(path, "r", encoding="utf-8") as fh: + return _checked(fh.read()) + except FileNotFoundError: + raise RefUsageError(f"no such file: {path}") + except UnicodeDecodeError: + # NOT an OSError, so the handler below never saw it and the + # decode error escaped as a raw traceback. `_checked` cannot + # catch this either: it inspects text, and there is no text yet. + raise RefUsageError(_NOT_TEXT.format(what=path)) + except OSError as exc: + raise RefUsageError(f"cannot read {path}: {exc}") + if not tty: + return _checked(_read_stream(stream)) + if prompt is not None: + prompt() + return _checked(_read_stream(stream)) + + +#: Said the same way whether the bytes arrived by path or down a pipe. +_NOT_TEXT = ( + "{what} is not UTF-8 text — this looks like a binary file rather than a policy" +) + + +def _read_stream(stream) -> str: + """Read stdin, turning undecodable bytes into a sentence. + + ``sys.stdin`` decodes as it reads, so piping a binary file raises + ``UnicodeDecodeError`` here rather than returning bytes ``_checked`` could + inspect — which is how `cat rule.png | fp policies publish x` printed a + traceback instead of the NUL-byte message written for exactly that mistake. + """ + try: + return stream.read() + except UnicodeDecodeError: + raise RefUsageError(_NOT_TEXT.format(what="the input")) + + +def _checked(text: str) -> str: + """Reject bytes the store cannot hold, with a message that says what happened. + + A NUL byte in policy source reaches Postgres and comes back as a bare + "database error" — a raw internal failure shown to somebody who most likely + pointed the command at a binary file by mistake. The server ought to refuse + it; until it does, refusing here turns an unexplained 500 into a sentence. + """ + if "\x00" in text: + raise RefUsageError( + "policy source contains a NUL byte — this looks like a binary file " + "rather than a policy" + ) + return text diff --git a/fp-cli/fp_cli/errors.py b/fp-cli/fp_cli/errors.py new file mode 100644 index 000000000..ca42a8cbe --- /dev/null +++ b/fp-cli/fp_cli/errors.py @@ -0,0 +1,98 @@ +"""CLI exception hierarchy. + +Errors subclass ``ClickException``, so Typer/Click catch them automatically, print the +message to stderr, and exit with ``exit_code``. The base class comes from +:mod:`._click_compat`, **not** from a plain ``import click``: Typer 0.26+ vendors its +own Click and catches only that one, so subclassing pip Click's ``ClickException`` +makes every typed error escape uncaught as exit 1 with an empty stderr. See +``_click_compat`` for the full failure mode. +""" + +from __future__ import annotations + +from typing import Optional + +from . import _click_compat as click # the Click Typer is running; see _click_compat + + +class FpCliError(click.ClickException): + """Base class for all CLI errors. + + Carries an optional ``hint`` (a short "what to do next" line). The single error + chokepoint in ``app.py`` renders it — under ``--json`` as a ``"hint"`` field, else + as the dim second line of the red error box — so commands just ``raise`` a typed + error with a hint instead of hand-rolling a JSON-vs-box branch per call site. + """ + + exit_code = 1 + + def __init__(self, message: str, *, hint: Optional[str] = None) -> None: + super().__init__(message) + self.hint = hint + + +class KeyModeUnsupportedError(FpCliError): + """This command cannot work with an API key (`--api-key` / ``FP_API_KEY``). + + Reuses **exit 2 (usage error)**, deliberately: the exit-code table is a scripted + contract restated in ``app.py``'s help, ``cli/skill/SKILL.md`` and + ``enterprise-docs/cli.md``, so a seventh code would have to be added to all three + at once — and "you asked for something this credential can never do" IS a usage + error. Every raise site fires BEFORE any HTTP call, so an unsupported command + never half-runs. + + Like every error here it subclasses through ``_click_compat``; bind it to pip + Click and Typer 0.26+ lets it escape as exit 1 with an empty stderr. + """ + + exit_code = 2 + + +class NetworkError(FpCliError): + """The dashboard could not be reached.""" + + exit_code = 3 + + +class AuthError(FpCliError): + """Not logged in, or the stored session has expired.""" + + exit_code = 4 + + +class ForbiddenError(FpCliError): + """Authenticated, but the account lacks the required permission.""" + + exit_code = 5 + + +class NotFoundError(FpCliError): + """The requested resource does not exist.""" + + exit_code = 6 + + +class ApiError(FpCliError): + """The dashboard returned an unexpected error status.""" + + exit_code = 1 + + def __init__( + self, + message: str, + *, + status: Optional[int] = None, + request_id: Optional[str] = None, + hint: Optional[str] = None, + ) -> None: + super().__init__(message, hint=hint) + self.status = status + self.request_id = request_id + + def format_message(self) -> str: + parts = [self.message] + if self.status is not None: + parts.append(f"(HTTP {self.status})") + if self.request_id: + parts.append(f"[request-id: {self.request_id}]") + return " ".join(parts) diff --git a/fp-cli/fp_cli/models.py b/fp-cli/fp_cli/models.py new file mode 100644 index 000000000..014b84669 --- /dev/null +++ b/fp-cli/fp_cli/models.py @@ -0,0 +1,891 @@ +"""Plain dataclasses mirroring the FailproofAI Cloud API shapes. + +These are deliberately free of any I/O or framework dependency so the client +layer (and a future MCP server) can return them directly. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, Generic, List, Optional, TypeVar + +T = TypeVar("T") + + +@dataclass +class Page(Generic[T]): + """A single page of cursor-paginated results.""" + + items: List[T] + next_cursor: Optional[int] = None + + +@dataclass +class OrgMembership: + """One org the operator belongs to, with the grants resolved for that org. + + Mirrors the dashboard's ``OrgMembership`` (``dashboard/lib/types.ts``): + permissions are now *per org*, replacing the old flat global list. + """ + + org_id: str + org_slug: str + org_name: str + permissions: List[str] = field(default_factory=list) + permission_set: Optional[str] = None + #: Operator-managed per-org feature flags (e.g. ``demo``). Empty when the org + #: has none, and also empty against a server predating the field — the two are + #: indistinguishable here by design, because every consumer treats "no flags" + #: and "flags unknown" the same way. + feature_flags: List[str] = field(default_factory=list) + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "OrgMembership": + return cls( + org_id=str(d.get("org_id", "")), + org_slug=str(d.get("org_slug", "")), + org_name=str(d.get("org_name", "")), + permissions=list(d.get("permissions") or []), + permission_set=d.get("permission_set"), + # from_dict is an explicit allowlist: a field absent from it is + # dropped silently, so `fp whoami --json` would omit the key + # entirely rather than report an empty set. + feature_flags=list(d.get("feature_flags") or []), + ) + + +@dataclass +class SessionUser: + """The authenticated operator. Multi-tenant: permissions live per-membership. + + The server dropped the flat ``permissions`` field (``dashboard/lib/session.ts``); + a user's effective grants depend on the *active org*. ``is_instance_admin`` may + browse orgs without a membership but gets no data permissions there. + """ + + id: str + email: str + is_instance_admin: bool = False + memberships: List[OrgMembership] = field(default_factory=list) + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "SessionUser": + return cls( + id=str(d.get("id", "")), + email=str(d.get("email", "")), + is_instance_admin=bool(d.get("is_instance_admin", False)), + memberships=[OrgMembership.from_dict(m) for m in (d.get("memberships") or [])], + ) + + def membership(self, org_slug: Optional[str]) -> Optional["OrgMembership"]: + if not org_slug: + return None + for m in self.memberships: + if m.org_slug == org_slug: + return m + return None + + def permissions_for(self, org_slug: Optional[str]) -> List[str]: + m = self.membership(org_slug) + return list(m.permissions) if m else [] + + @property + def org_slugs(self) -> List[str]: + return [m.org_slug for m in self.memberships if m.org_slug] + + +@dataclass +class AgentEvent: + """One event row. Two server sources feed this model: + + * ``GET /api/events`` (full) — carries the fat ``payload`` column. Used only by the + opt-in heavy path (``events --full`` / ``--fields payload``). + * ``GET /api/events/summary`` (light) — payload-FREE. Carries the server-precomputed + ``summary`` / ``is_error`` plus the promoted ``error_type`` / ``output_tokens`` + columns. The default ``events`` view and all of ``errors`` use this, so their + responses never include the fat payload (a free-text search may still scan it + server-side). + + Fields absent on one source default cleanly (``payload`` → ``{}`` on light rows; + ``summary``/``is_error``/… → empty on full rows), so a single model serves both. + """ + + id: int + session_id: str + agent_id: str + event_type: str + ts: str + payload: Dict[str, Any] = field(default_factory=dict) + environment: str = "" + # Light-feed columns (GET /events/summary) — server-precomputed, never derived from + # payload client-side. + summary: str = "" + is_error: bool = False + error_type: Optional[str] = None + output_tokens: Optional[int] = None + # Context-window checker: present on BOTH feeds (null for non-model events / unknown + # models). Now surfaced in --json instead of being silently dropped. + context_window: Optional[int] = None + context_fill: Optional[float] = None + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "AgentEvent": + try: + event_id = int(d.get("id", 0)) + except (TypeError, ValueError): + event_id = 0 # tolerate a null/non-numeric id rather than crashing the render + return cls( + id=event_id, + session_id=str(d.get("session_id", "")), + agent_id=str(d.get("agent_id", "")), + event_type=str(d.get("event_type", "")), + ts=str(d.get("ts", "")), + payload=d.get("payload") or {}, + environment=str(d.get("environment", "")), + summary=str(d.get("summary") or ""), + is_error=bool(d.get("is_error", False)), + error_type=d.get("error_type"), + output_tokens=d.get("output_tokens"), + context_window=d.get("context_window"), + context_fill=d.get("context_fill"), + ) + + +@dataclass +class Evaluation: + id: str # evaluation_id is a UUID (Postgres), not an integer + session_id: str + agent_id: str + environment: str + status: str + scores: Optional[Dict[str, float]] = None + reasoning: Optional[Dict[str, str]] = None + summary: Optional[str] = None + error: Optional[str] = None + attempt_count: int = 0 + duration_ms: Optional[int] = None + completed_at: str = "" + created_at: str = "" + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "Evaluation": + return cls( + id=str(d.get("id", "")), + session_id=str(d.get("session_id", "")), + agent_id=str(d.get("agent_id", "")), + environment=str(d.get("environment", "")), + status=str(d.get("status", "")), + scores=d.get("scores"), + reasoning=d.get("reasoning"), + summary=d.get("summary"), + error=d.get("error"), + attempt_count=int(d.get("attempt_count", 0)), + duration_ms=d.get("duration_ms"), + completed_at=str(d.get("completed_at", "")), + created_at=str(d.get("created_at", "")), + ) + + +@dataclass +class Session: + """One agent run — a row from the dashboard ``/api/sessions`` endpoint (the same + source the dashboard's sessions page uses), so the CLI's filters match it exactly. + + A session's terminal **evaluation** (if any) arrives nested under + ``latest_evaluation``. For backward compatibility with the eval-shaped renderer and + with ``--json`` / ``--fields`` consumers, ``status`` and ``scores`` are **flattened + up** to the top level from that nested object (the full nested object is preserved as + ``latest_evaluation`` for completeness). A session that was never evaluated has an + empty ``status``/``scores`` and ``latest_evaluation = None``. + """ + + session_id: str + agent_id: str + environment: str + # Flattened up from latest_evaluation (back-compat: top-level status/scores). + status: str = "" + scores: Optional[Dict[str, float]] = None + # Full roster of every agent that ran in this session, server-sorted by + # event_count desc — ``[{"agent_id": str, "event_count": int}, ...]``. + # ``agent_id`` above is the root (first agent_start); ``agents`` is the whole + # cast. ``None`` on older servers that predate the multi-agent roster. + agents: Optional[List[Dict[str, Any]]] = None + # Session-level fields. + event_count: int = 0 + started_at: str = "" + last_event_at: str = "" + first_event_id: Optional[int] = None + last_event_id: Optional[int] = None + # The full terminal evaluation (or None if the session was never evaluated). + latest_evaluation: Optional[Dict[str, Any]] = None + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "Session": + le = d.get("latest_evaluation") or {} + return cls( + session_id=str(d.get("session_id", "")), + agent_id=str(d.get("agent_id", "")), + environment=str(d.get("environment", "")), + status=str(le.get("status", "")), # flattened up for the renderer + back-compat + scores=le.get("scores"), # flattened up + agents=d.get("agents"), # full agent roster (list of {agent_id, event_count}) + event_count=int(d.get("event_count", 0)), + started_at=str(d.get("started_at", "")), + last_event_at=str(d.get("last_event_at", "")), + first_event_id=d.get("first_event_id"), + last_event_id=d.get("last_event_id"), + latest_evaluation=d.get("latest_evaluation"), + ) + + +@dataclass +class ApiKey: + id: str + name: str + permissions: List[str] = field(default_factory=list) + created_at: str = "" + revoked_at: Optional[str] = None + # The server sends this; `from_dict` is an allowlist, so omitting it here + # silently dropped it and `fp keys list` showed an expired key as + # active. None = never expires. + expires_at: Optional[str] = None + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "ApiKey": + return cls( + id=str(d.get("id", "")), + name=str(d.get("name", "")), + permissions=list(d.get("permissions") or []), + created_at=str(d.get("created_at", "")), + revoked_at=d.get("revoked_at"), + expires_at=d.get("expires_at"), + ) + + +@dataclass +class SavedQuery: + id: str + name: str + description: str = "" + sql_text: str = "" + params: List[Dict[str, Any]] = field(default_factory=list) + created_by: Optional[str] = None + created_at: str = "" + updated_at: str = "" + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "SavedQuery": + return cls( + id=str(d.get("id", "")), + name=str(d.get("name", "")), + description=str(d.get("description", "")), + sql_text=str(d.get("sql_text", "")), + params=list(d.get("params") or []), + created_by=d.get("created_by"), + created_at=str(d.get("created_at", "")), + updated_at=str(d.get("updated_at", "")), + ) + + +@dataclass +class QueryResult: + columns: List[Dict[str, str]] = field(default_factory=list) + rows: List[List[Any]] = field(default_factory=list) + truncated: bool = False + elapsed_ms: int = 0 + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "QueryResult": + return cls( + columns=list(d.get("columns") or []), + rows=list(d.get("rows") or []), + truncated=bool(d.get("truncated", False)), + elapsed_ms=int(d.get("elapsed_ms", 0)), + ) + + +@dataclass +class DashboardUser: + id: str + email: str + permissions: List[str] = field(default_factory=list) + permission_set: Optional[str] = None + permission_added: List[str] = field(default_factory=list) + permission_removed: List[str] = field(default_factory=list) + disabled_at: Optional[str] = None + is_protected: bool = False + created_at: str = "" + updated_at: str = "" + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "DashboardUser": + return cls( + id=str(d.get("id", "")), + email=str(d.get("email", "")), + permissions=list(d.get("permissions") or []), + permission_set=d.get("permission_set"), + permission_added=list(d.get("permission_added") or []), + permission_removed=list(d.get("permission_removed") or []), + disabled_at=d.get("disabled_at"), + is_protected=bool(d.get("is_protected", False)), + created_at=str(d.get("created_at", "")), + updated_at=str(d.get("updated_at", "")), + ) + + +@dataclass +class SettingRow: + key: str + value: Any = None + updated_at: str = "" + updated_by: Optional[str] = None + scope: Optional[str] = None + schema: Optional[Dict[str, Any]] = None + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "SettingRow": + return cls( + key=str(d.get("key", "")), + value=d.get("value"), + updated_at=str(d.get("updated_at", "")), + updated_by=d.get("updated_by"), + scope=d.get("scope"), + schema=d.get("schema"), + ) + + +@dataclass +class Alert: + id: str + name: str + description: Optional[str] = None + enabled: bool = True + trigger_kind: str = "" + trigger_spec: Dict[str, Any] = field(default_factory=dict) + min_breaches: int = 1 + eval_window: int = 1 + eval_interval_secs: int = 0 + severity: str = "" + channels: List[Dict[str, Any]] = field(default_factory=list) + created_by: str = "" + created_at: str = "" + updated_at: str = "" + last_attempted_at: Optional[str] = None + open_incidents: int = 0 + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "Alert": + return cls( + id=str(d.get("id", "")), + name=str(d.get("name", "")), + description=d.get("description"), + enabled=bool(d.get("enabled", True)), + trigger_kind=str(d.get("trigger_kind", "")), + trigger_spec=d.get("trigger_spec") or {}, + min_breaches=int(d.get("min_breaches", 1)), + eval_window=int(d.get("eval_window", 1)), + eval_interval_secs=int(d.get("eval_interval_secs", 0)), + severity=str(d.get("severity", "")), + channels=list(d.get("channels") or []), + created_by=str(d.get("created_by", "")), + created_at=str(d.get("created_at", "")), + updated_at=str(d.get("updated_at", "")), + last_attempted_at=d.get("last_attempted_at"), + open_incidents=int(d.get("open_incidents", 0)), + ) + + +@dataclass +class Incident: + id: str + #: Short identifying line. Present on every issue since the issues redesign; + #: `alert_name` is only set for the minority that have a parent alert, so + #: this is the column that actually distinguishes rows. + title: Optional[str] = None + #: How the issue came to exist: 'manual' | 'alert' | 'audit'. + source: Optional[str] = None + #: For source='audit', the audit finding this issue was opened from. + source_finding_id: Optional[str] = None + alert_id: Optional[str] = None + alert_name: Optional[str] = None + alert_severity: str = "" + trigger_kind: Optional[str] = None + state: str = "" + opened_at: str = "" + last_breach_at: str = "" + acknowledged_at: Optional[str] = None + acknowledged_by: Optional[str] = None + assignees: List[str] = field(default_factory=list) + resolved_at: Optional[str] = None + breach_value: Optional[float] = None + breach_summary: Optional[str] = None + evidence: Optional[Dict[str, Any]] = None + notifications: Optional[List[Dict[str, Any]]] = None + subscribers: Optional[List[Dict[str, Any]]] = None + comments: Optional[List[Dict[str, Any]]] = None + activity: Optional[List[Dict[str, Any]]] = None + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "Incident": + return cls( + id=str(d.get("id", "")), + title=d.get("title"), + source=d.get("source"), + source_finding_id=d.get("source_finding_id"), + alert_id=d.get("alert_id"), + alert_name=d.get("alert_name"), + alert_severity=str(d.get("alert_severity", "")), + trigger_kind=d.get("trigger_kind"), + state=str(d.get("state", "")), + opened_at=str(d.get("opened_at", "")), + last_breach_at=str(d.get("last_breach_at", "")), + acknowledged_at=d.get("acknowledged_at"), + acknowledged_by=d.get("acknowledged_by"), + assignees=list(d.get("assignees") or []), + resolved_at=d.get("resolved_at"), + breach_value=d.get("breach_value"), + breach_summary=d.get("breach_summary"), + evidence=d.get("evidence"), + notifications=d.get("notifications"), + subscribers=d.get("subscribers"), + comments=d.get("comments"), + activity=d.get("activity"), + ) + + +@dataclass +class IncidentComment: + id: str + incident_id: str = "" + author_email: str = "" + body: Optional[str] = None + created_at: str = "" + edited_at: Optional[str] = None + deleted_at: Optional[str] = None + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "IncidentComment": + return cls( + id=str(d.get("id", "")), + incident_id=str(d.get("incident_id", "")), + author_email=str(d.get("author_email", "")), + body=d.get("body"), + created_at=str(d.get("created_at", "")), + edited_at=d.get("edited_at"), + deleted_at=d.get("deleted_at"), + ) + + +@dataclass +class IncidentSubscriber: + email: str + source: str = "" + subscribed_at: str = "" + unsubscribed_at: Optional[str] = None + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "IncidentSubscriber": + return cls( + email=str(d.get("email", "")), + source=str(d.get("source", "")), + subscribed_at=str(d.get("subscribed_at", "")), + unsubscribed_at=d.get("unsubscribed_at"), + ) + + +def _as_int(value: Any, default: int = 0) -> int: + """A JSON value → int, falling back to ``default`` on null/garbage. + + ``int(d.get(k, default))`` raises on an explicit ``null`` (``int(None)``) or a + non-numeric string, which would crash a whole render over one bad row. The audit + models use this so ``from_dict`` can never raise. + """ + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _as_float(value: Any, default: float = 0.0) -> float: + """A JSON value → float, falling back to ``default`` on null/garbage (see :func:`_as_int`).""" + try: + return float(value) + except (TypeError, ValueError): + return default + + +@dataclass +class Audit: + """One audit definition — a scheduled sweep over a window of agent activity that + produces **findings**. + + The definition columns (schedule, window, scope, signals, LLM settings, channels) are + what ``audits create``/``edit`` write; the trailing fields are server-derived read-only + state the list/get endpoints join in (``open_findings``, the last run's status/time, and + the queue row's attempt timestamps). Timestamps stay raw ISO strings — the renderers + humanize them, ``--json`` passes them through untouched. + """ + + id: str + name: str + description: Optional[str] = None + enabled: bool = True + schedule_interval_secs: int = 86400 + # Fixed phase for the schedule: runs land on `anchor + N * interval`, so a slow + # run or a manual trigger can't drift the cadence. Raw ISO string, like the + # other timestamps. Server defaults it to the next 09:00 UTC when omitted; + # None only for legacy rows written before the column existed. + schedule_anchor: Optional[str] = None + window_mode: str = "since_last" # 'fixed' | 'since_last' + lookback_window_secs: int = 604800 + scope: Dict[str, Any] = field(default_factory=dict) + ignore_error_types: List[str] = field(default_factory=list) + llm_enabled: bool = True + top_k: int = 50 + sensitivity: str = "medium" # 'low' | 'medium' | 'high' + channels: List[Dict[str, Any]] = field(default_factory=list) + created_by: str = "" + created_at: str = "" + updated_at: str = "" + # Server-derived, read-only (never sent back on a write). + open_findings: int = 0 + last_run_status: Optional[str] = None + last_run_finished_at: Optional[str] = None + last_attempted_at: Optional[str] = None + next_attempt_at: Optional[str] = None + last_error: Optional[str] = None + # Operator brief appended to the analysis prompt. READ-ONLY on this model: + # it is written through `fp audits context set`, never as part of a + # definition body, so a flag-only `audits edit` (which read-merges from + # _audit_to_body) can never wipe it. + additional_context: str = "" + reference_url_count: int = 0 + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "Audit": + return cls( + id=str(d.get("id", "")), + name=str(d.get("name", "")), + description=d.get("description"), + enabled=bool(d.get("enabled", True)), + schedule_interval_secs=_as_int(d.get("schedule_interval_secs"), 86400), + schedule_anchor=d.get("schedule_anchor"), + window_mode=str(d.get("window_mode", "") or "since_last"), + lookback_window_secs=_as_int(d.get("lookback_window_secs"), 604800), + scope=d.get("scope") or {}, + ignore_error_types=list(d.get("ignore_error_types") or []), + llm_enabled=bool(d.get("llm_enabled", True)), + top_k=_as_int(d.get("top_k"), 50), + sensitivity=str(d.get("sensitivity", "") or "medium"), + channels=list(d.get("channels") or []), + created_by=str(d.get("created_by", "")), + created_at=str(d.get("created_at", "")), + updated_at=str(d.get("updated_at", "")), + open_findings=_as_int(d.get("open_findings"), 0), + last_run_status=d.get("last_run_status"), + last_run_finished_at=d.get("last_run_finished_at"), + last_attempted_at=d.get("last_attempted_at"), + next_attempt_at=d.get("next_attempt_at"), + last_error=d.get("last_error"), + additional_context=str(d.get("additional_context", "") or ""), + reference_url_count=_as_int(d.get("reference_url_count"), 0), + ) + + +@dataclass +class AuditRun: + """One execution of an audit — the window it swept, how it ended, and what it produced. + + ``stats`` is an opaque per-run counter object and ``report`` the rendered summary text + (both may be absent on a run that failed early), so neither is parsed here. + """ + + id: str + audit_id: str = "" + status: str = "" # 'running' | 'succeeded' | 'failed' + trigger_kind: str = "" + window_from: str = "" + window_to: str = "" + started_at: str = "" + finished_at: Optional[str] = None + stats: Dict[str, Any] = field(default_factory=dict) + findings_count: int = 0 + new_findings_count: int = 0 + report: Optional[str] = None + error: Optional[str] = None + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "AuditRun": + return cls( + id=str(d.get("id", "")), + audit_id=str(d.get("audit_id", "")), + status=str(d.get("status", "")), + trigger_kind=str(d.get("trigger_kind", "")), + window_from=str(d.get("window_from", "")), + window_to=str(d.get("window_to", "")), + started_at=str(d.get("started_at", "")), + finished_at=d.get("finished_at"), + stats=d.get("stats") or {}, + findings_count=_as_int(d.get("findings_count"), 0), + new_findings_count=_as_int(d.get("new_findings_count"), 0), + report=d.get("report"), + error=d.get("error"), + ) + + +@dataclass +class AuditFinding: + """One finding — a recurring pattern an audit surfaced, carried across runs by its + ``fingerprint`` and triaged through ``status``. + + ``priority`` is the server's ranking score (findings arrive priority-desc); + ``evidence``/``evidence_queries``/``scope`` are opaque blobs shown verbatim. + """ + + id: str + audit_id: str = "" + audit_name: str = "" + fingerprint: str = "" + title: str = "" + category: Optional[str] = None + failure_type: str = "" + description: Optional[str] = None + root_cause_hypothesis: Optional[str] = None + severity: str = "" # 'info' | 'warning' | 'critical' + magnitude: Optional[str] = None # 'small' | 'medium' | 'big' + priority: float = 0.0 + status: str = "" # 'open' | 'recurring' | 'resolved' | 'dismissed' | 'muted' + occurrences: int = 0 + first_seen_at: str = "" + last_seen_at: str = "" + recommendation: Optional[str] = None + expected_impact: Optional[str] = None + effort: Optional[str] = None + evidence: Dict[str, Any] = field(default_factory=dict) + evidence_queries: List[Any] = field(default_factory=list) + scope: Dict[str, Any] = field(default_factory=dict) + kind: str = "" # 'improvement' | 'policy' | 'failure' + assigned_to: Optional[str] = None + # The issue this finding graduated into, or None if it never linked. + # Rising nulls on open/recurring findings is how you see issue_sync + # degrading; `from_dict` is an allowlist, so omitting it here silently + # drops the field rather than erroring. + issue_id: Optional[str] = None + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "AuditFinding": + return cls( + id=str(d.get("id", "")), + audit_id=str(d.get("audit_id", "")), + audit_name=str(d.get("audit_name", "")), + fingerprint=str(d.get("fingerprint", "")), + title=str(d.get("title", "")), + category=d.get("category"), + failure_type=str(d.get("failure_type", "")), + description=d.get("description"), + root_cause_hypothesis=d.get("root_cause_hypothesis"), + severity=str(d.get("severity", "")), + magnitude=d.get("magnitude"), + priority=_as_float(d.get("priority"), 0.0), + status=str(d.get("status", "")), + occurrences=_as_int(d.get("occurrences"), 0), + first_seen_at=str(d.get("first_seen_at", "")), + last_seen_at=str(d.get("last_seen_at", "")), + recommendation=d.get("recommendation"), + expected_impact=d.get("expected_impact"), + effort=d.get("effort"), + evidence=d.get("evidence") or {}, + evidence_queries=list(d.get("evidence_queries") or []), + scope=d.get("scope") or {}, + kind=str(d.get("kind", "")), + assigned_to=d.get("assigned_to"), + issue_id=d.get("issue_id"), + ) + + +# ── Cloud-managed enforcement ──────────────────────────────────────────────── +# +# Three nouns, and keeping them apart is the whole model. A POLICY VERSION is +# written; a DEPLOYMENT says which versions a MACHINE is told to run. The +# dashboard splits them across three pages for the same reason — authoring is a +# code task, deploying is a fleet decision, and observing is neither. + + +@dataclass +class PolicyVersion: + """One published version of a policy. Versions are minted, never edited.""" + + id: str + version: int + description: str + sha256: str + source: Optional[str] + created_at: str + created_by: Optional[str] + disabled: bool + archived: bool + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "PolicyVersion": + return cls( + id=str(d.get("id", "")), + version=_as_int(d.get("version"), 0), + description=str(d.get("description", "") or ""), + sha256=str(d.get("sha256", "") or ""), + source=d.get("source"), + created_at=str(d.get("createdAt", d.get("created_at", "")) or ""), + created_by=d.get("createdBy", d.get("created_by")), + disabled=bool(d.get("disabled", False)), + archived=bool(d.get("archived", False)), + ) + + def to_dict(self) -> Dict[str, Any]: + """The server's own shape. `vars()` would leak Python snake_case into a + contract that is camelCase everywhere else, which is a difference a + harness discovers at runtime rather than in review.""" + return { + "id": self.id, "version": self.version, "description": self.description, + "sha256": self.sha256, "source": self.source, "createdAt": self.created_at, + "createdBy": self.created_by, "disabled": self.disabled, + "archived": self.archived, + } + + +@dataclass +class PolicyRef: + """A policy inside a deployment: which version, and how it acts. + + ``effect`` is ``enforce`` or ``observe``. The server defaults an omitted + effect to ``enforce``; the CLI always sends it explicitly so a deployment + read back and written again cannot silently change meaning. + """ + + id: str + version: int + effect: str = "enforce" + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "PolicyRef": + return cls( + id=str(d.get("id", "")), + version=_as_int(d.get("version"), 0), + effect=str(d.get("effect") or "enforce"), + ) + + def to_dict(self) -> Dict[str, Any]: + return {"id": self.id, "version": self.version, "effect": self.effect} + + @property + def label(self) -> str: + return f"{self.id}@{self.version}:{self.effect}" + + +@dataclass +class Deployment: + """What one machine is told to enforce, and which generation that is. + + ``deployment`` is the generation counter. It is the CLI's only defence + against a concurrent write: ``PUT`` is a FULL REPLACE with no server-side + lock, so a deploy that returns anything other than ``base + 1`` means + somebody else wrote between the read and the write. + """ + + machine_id: str + deployment: int + policies: List[PolicyRef] + updated_at: str + updated_by: Optional[str] + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "Deployment": + return cls( + machine_id=str(d.get("machineId", d.get("machine_id", "")) or ""), + deployment=_as_int(d.get("deployment"), 0), + policies=[PolicyRef.from_dict(p) for p in (d.get("policies") or [])], + updated_at=str(d.get("updatedAt", d.get("updated_at", "")) or ""), + updated_by=d.get("updatedBy", d.get("updated_by")), + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "machineId": self.machine_id, "deployment": self.deployment, + "policies": [p.to_dict() for p in self.policies], + "updatedAt": self.updated_at, "updatedBy": self.updated_by, + } + + +@dataclass +class Machine: + """A host that has checked in. Machines enrol themselves on their first poll. + + Two generation numbers, and the gap between them is the whole point of + `fleet diff`: ``deployment`` is what the control plane INTENDED for this + machine, ``applied_deployment`` is what the machine last actually collected. + A machine can sit on an old set indefinitely and nothing else says so. + """ + + machine_id: str + #: What the machine calls itself. May be absent — plenty never report one. + label: Optional[str] + #: What an operator called it via `fleet rename`. SEPARATE from `label` on + #: the server, and the reason a rename appeared to do nothing here: reading + #: only `label` showed the machine's own (usually null) name and silently + #: ignored the override. `display_label` applies the precedence. + label_override: Optional[str] + last_seen: Optional[int] # epoch ms — the server sends a number, not ISO + last_check_in: Optional[int] + deployment: Optional[int] # intended + applied_deployment: Optional[int] # delivered + applied_at: Optional[int] + deployed: bool + policy_count: int + event_count: int + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "Machine": + def _num(key: str) -> Optional[int]: + v = d.get(key) + return int(v) if isinstance(v, (int, float)) else None + + return cls( + machine_id=str(d.get("machineId", d.get("machine_id", "")) or ""), + label=d.get("label"), + label_override=d.get("labelOverride"), + last_seen=_num("lastSeen"), + last_check_in=_num("lastCheckIn"), + deployment=_num("deployment"), + applied_deployment=_num("appliedDeployment"), + applied_at=_num("appliedAt"), + deployed=bool(d.get("deployed", False)), + policy_count=_as_int(d.get("policyCount"), 0), + event_count=_as_int(d.get("eventCount"), 0), + ) + + @property + def display_label(self) -> Optional[str]: + """The operator's name for the machine, else its own. + + Mirrors `machinePicker.ts`: `labelOverride || label || machineId`. The + override wins because it is the deliberate one — a machine's + self-asserted label is whatever it happened to send. + """ + return (self.label_override or "").strip() or (self.label or "").strip() or None + + def to_dict(self) -> Dict[str, Any]: + """Server shape plus `drifted` — the one field the CLI computes.""" + return { + "machineId": self.machine_id, "label": self.label, + "labelOverride": self.label_override, + "lastSeen": self.last_seen, "lastCheckIn": self.last_check_in, + "deployment": self.deployment, "appliedDeployment": self.applied_deployment, + "appliedAt": self.applied_at, "deployed": self.deployed, + "policyCount": self.policy_count, "eventCount": self.event_count, + "drifted": self.drifted, + } + + @property + def drifted(self) -> bool: + """True when the machine has not collected what it was last told to run.""" + if self.deployment is None: + return False + return self.applied_deployment is None or self.applied_deployment < self.deployment diff --git a/fp-cli/fp_cli/orgs.py b/fp-cli/fp_cli/orgs.py new file mode 100644 index 000000000..5a61f8bc3 --- /dev/null +++ b/fp-cli/fp_cli/orgs.py @@ -0,0 +1,30 @@ +"""Org-slug helpers (multi-tenant). + +The dashboard is path-routed by an org slug and validates it against a strict +pattern (see ``dashboard/lib/org.ts``). The CLI validates the same shape before +sending an ``X-AgentEye-Org`` header so a typo fails fast with a clear message +instead of a confusing server error. +""" + +from __future__ import annotations + +import re + +# lowercase alphanumeric + single hyphens, 1-40 chars (mirrors dashboard SLUG_RE +# and the server's orgs.slug CHECK constraint). Keep all three in sync. +_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + +# Reserved first path segments that can never be an org slug (dashboard/lib/org.ts). +RESERVED_ORG_SLUGS = frozenset( + {"api", "login", "admin", "ingest", "_next", "favicon.ico", "auth"} +) + + +def is_valid_org_slug(slug: object) -> bool: + if not isinstance(slug, str) or not slug: + return False + if len(slug) > 40: + return False + if slug in RESERVED_ORG_SLUGS: + return False + return bool(_SLUG_RE.match(slug)) diff --git a/fp-cli/fp_cli/output.py b/fp-cli/fp_cli/output.py new file mode 100644 index 000000000..ecf387690 --- /dev/null +++ b/fp-cli/fp_cli/output.py @@ -0,0 +1,6505 @@ +"""Rendering helpers. + +Data goes to **stdout** (a JSON document with ``--json``, or a Rich table for +humans). Human chatter — status lines, hints, errors — goes to **stderr**, so +``--json`` stdout is always clean and machine-parseable. +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import json as _json +import math +import re +from datetime import datetime, timezone +from typing import Any, List, Optional, Sequence + +import typer +from rich.box import ROUNDED, SIMPLE_HEAD +from rich.console import Console, Group +from rich.padding import Padding +from rich.panel import Panel +from rich.rule import Rule +from rich.table import Table +from rich.text import Text + +from . import theme + +_stdout = Console() +_stderr = Console(stderr=True) +_quiet = False +_no_color = False +_json_out = False + + +def configure(*, no_color: bool = False, quiet: bool = False, json: bool = False) -> None: + """Reconfigure the consoles from the resolved global flags.""" + global _stdout, _stderr, _quiet, _no_color, _json_out + _stdout = Console(no_color=no_color) + _stderr = Console(stderr=True, no_color=no_color) + _quiet = quiet + _no_color = no_color + _json_out = json + + +def is_json() -> bool: + """Whether the active invocation requested ``--json`` (set by :func:`configure`). + + Lets the single error chokepoint in ``app.py`` decide between a JSON error envelope + (stdout) and the human red box (stderr) without threading ``AppState`` into Click's + exception renderer. + """ + return _json_out + + +@contextlib.contextmanager +def thinking(label: str = "thinking…", *, enabled: bool = True): + """A themed braille spinner on **stderr** while a slow call runs (e.g. the assistant + streaming its reply, which otherwise looks hung). A no-op when ``enabled`` is False, + ``--quiet`` is set, or stderr isn't a TTY — so piped / scripted / ``--json`` output stays + clean (data on stdout is never touched).""" + if not enabled or _quiet or not _stderr.is_terminal: + yield + return + with _stderr.status(Text(label, style=theme.ACCENT), spinner="dots", spinner_style=theme.ACCENT): + yield + + +def _ansi(text: str, **style: Any) -> str: + """A click-styled string for use in input prompts, or plain when colour is off.""" + if _no_color: + return text + return typer.style(text, **style) + + +def prompt(label: str, *, default: Optional[str] = None, hide_input: bool = False) -> str: + """A styled, indented input prompt on stderr — `❯ label <input>` — so prompts share + the indentation/accent of the status lines. Wraps ``typer.prompt`` unchanged, so the + input mechanism (and the CliRunner test input) behaves exactly as before.""" + # Pad the label so the typed values line up across email / code / org. + text = _ansi(" ❯ ", fg=_ACCENT, bold=True) + _ansi(label.ljust(5), bold=True) + kwargs: dict = {"err": True, "prompt_suffix": " ", "hide_input": hide_input} + if default is not None: + kwargs["default"] = default + return typer.prompt(text, **kwargs) + + +def _json_default(obj: Any) -> Any: + if dataclasses.is_dataclass(obj) and not isinstance(obj, type): + return dataclasses.asdict(obj) + return str(obj) + + +def _json_safe(obj: Any) -> Any: + """Map non-finite floats (NaN/Infinity) to ``null`` so the output is always valid JSON. + + A single ``NaN`` from a server aggregate would otherwise make ``emit_json`` print a + bare ``NaN`` token that breaks ``jq`` / ``JSON.parse`` for any consumer. Walks dicts, + lists and dataclasses (flattening the latter the same way ``_json_default`` would). + """ + if isinstance(obj, float): + return obj if math.isfinite(obj) else None + if isinstance(obj, dict): + return {k: _json_safe(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_json_safe(v) for v in obj] + if dataclasses.is_dataclass(obj) and not isinstance(obj, type): + return _json_safe(dataclasses.asdict(obj)) + return obj + + +def emit_json(obj: Any) -> None: + """Write a JSON document to stdout (no Rich markup interpretation). + + Non-finite floats are coerced to ``null`` and ``allow_nan=False`` is a backstop, so the + document is always parseable by standard JSON consumers. + """ + print(_json.dumps(_json_safe(obj), indent=2, ensure_ascii=False, + default=_json_default, allow_nan=False)) + + +def print_table( + columns: Sequence[str], + rows: Sequence[Sequence[Any]], + *, + title: Optional[str] = None, + show_header: bool = True, +) -> None: + table = Table( + title=title, title_justify="left", header_style="bold", show_header=show_header + ) + for column in columns: + table.add_column(str(column), overflow="fold") + for row in rows: + # Wrap each cell in Text so a value containing Rich markup (e.g. "[/]" or + # "[red]" in a server-provided field) renders literally instead of raising + # MarkupError mid-render (which would abort with a raw traceback). + table.add_row(*[Text(_cell(value)) for value in row]) + _stdout.print(table) + + +def _cell(value: Any) -> str: + if value is None: + return "-" + return str(value) + + +def info(message: str) -> None: + if not _quiet: + _stderr.print(message) + + +def success(message: str) -> None: + if not _quiet: + _stderr.print(message, style="green") + + +def hint(message: str) -> None: + if not _quiet: + _stderr.print(message, style="dim") + + +def error(message: str) -> None: + _stderr.print(message, style="bold red") + + +def warn(message: str) -> None: + if not _quiet: + _stderr.print(message, style="yellow") + + +# ── Auth experience (login / logout) — presentation only ──────────────────── +# A light, cohesive treatment for the first commands a user runs. Everything +# here is stderr status chrome (never the --json data on stdout); the accent is +# the project's pink/magenta. All gated on `_quiet`. + +_ACCENT = "magenta" +_NEUTRAL = "grey62" # gray for 'already in that state' no-op boxes (neutral, not red/green) + + +def auth_header() -> None: + """The sign-in header: a branded wordmark + a warm, one-line welcome.""" + if _quiet: + return + _stderr.print() + _stderr.print(f" [bold {_ACCENT}]◆ fp[/]") + _stderr.print(" [dim]welcome — let's get you signed in with a one-time code[/]") + _stderr.print() + + +def step(message: str) -> None: + """A single styled step line in a flow (e.g. 'sending a code …').""" + if _quiet: + return + _stderr.print(f" [{_ACCENT}]›[/] {message}") + + +def code_sent(email: str) -> None: + """Confirm the one-time code was emailed (the completed `request_otp` step).""" + if _quiet: + return + _stderr.print(f" [green]✓[/] code sent to [bold {_ACCENT}]{email}[/]") + + +def org_picker(slugs: Sequence[str], current: Optional[str] = None) -> None: + """Render the multi-tenant org list above the selection prompt.""" + if _quiet: + return + _stderr.print() + _stderr.print(f" [bold]choose your org[/] [dim]· {len(slugs)} available[/]") + _stderr.print() + for i, slug in enumerate(slugs, 1): + mark = " [dim]· current[/]" if slug == current else "" + _stderr.print(f" [bold {_ACCENT}]{i}[/] [{_ACCENT}]›[/] [bold]{slug}[/]{mark}") + _stderr.print() + + +def signed_in(email: str, org: Optional[str]) -> None: + """Success box for `login` (green `✓`) — the warm landing of the first flow.""" + if _quiet: + return + body = Text() + body.append(email, style="bold") + if org: + body.append(" · ", style="dim") + body.append(org, style=_ACCENT) + _panel("✓", "green", "signed in", body) + + +def already_signed_in(email: Optional[str], org: Optional[str]) -> None: + """`login` when a valid session already exists — a neutral, boxed status (NOT the red + error box; nothing failed, the command exits 0). Says who you are + how to switch.""" + if _quiet: + return + who = email or "your account" + body = Text() + body.append(who, style="bold") + if org: + body.append(" · ", style="dim") + body.append(org, style=_ACCENT) + _status_box( + "already signed in", + body, + hint="run fp logout to switch accounts, or login --force to re-authenticate", + ) + + +def signed_out() -> None: + """Success box for `logout` (green `✓`) — parallels `already signed out`.""" + if _quiet: + return + _panel("✓", "green", "signed out", Text("session ended"), + hint="run fp login to sign back in") + + +# ── interactive login — the single-box flow (one Live panel, redrawn in place) ── +# The whole `fp login` renders inside ONE outer Panel with `◆ fp` on the top +# border as a legend. Steps progress in place: each completed step collapses to a dim `✓` +# line; the active step is the bright `❯` line (or the nested org-picker inset). The border +# flips ACCENT → SUCCESS once signed in. Chrome → stderr; only `--json` writes stdout. + +def _login_slots(buf: str, slots: int) -> Text: + """Render the typed code as fixed slots — ``9 6 7 _ _ _`` (typed digits, ``_`` for the rest).""" + chars = list(buf)[:slots] + out = Text() + for i in range(slots): + if i: + out.append(" ", style=theme.FAINT) + if i < len(chars): + out.append(chars[i], style=f"bold {theme.TEXT}") + else: + out.append("_", style=theme.FAINT) + return out + + +def login_inset(slugs: Sequence[str], idx: int): + """The nested org-picker inset (a renderable inside the login frame): a ``choose your org · N`` + title, then a FAINT-bordered inset box (a hair-lighter fill) with one row per org — ``❯`` cursor + (ACCENT) on row ``idx``, selected slug bright, others dim — then a FAINT key-hint line.""" + title = Text() + title.append("choose your org", style=f"bold {theme.TEXT}") + title.append(f" · {len(slugs)}", style=theme.FAINT) + table = Table(box=None, show_header=False, pad_edge=False, padding=(0, 2, 0, 0)) + table.add_column(no_wrap=True) + table.add_column(no_wrap=True) + for i, slug in enumerate(slugs): + selected = i == idx + ptr = Text("❯", style=f"bold {theme.ACCENT}") if selected else Text(" ") + name = Text(str(slug), style=theme.TEXT if selected else theme.TEXT_DIM) + table.add_row(ptr, name) + inset = Panel(table, box=ROUNDED, border_style=theme.FAINT, style=f"on {theme.INSET_BG}", + padding=(0, 1), expand=False) + hint = Text("↑↓ move · ⏎ select · esc cancel", style=theme.FAINT) + return Group(title, inset, hint) + + +def render_login_frame(done, active, *, active_value: str = "", active_slots: Optional[int] = None, + helper=None, error=None, inset=None, note=None, signed_in=None, + cancelled=None, failed=None): + """Build the one login Panel (a renderable the caller redraws via ``Live``). ``done`` is a list + of ``(label, value)`` collapsed ✓ steps; ``active`` is the current step label (bright ``❯`` + line) with ``active_value`` the in-progress typed text (rendered as fixed slots when + ``active_slots`` is set, e.g. the 6-digit code). ``inset`` is the org-picker block; ``signed_in`` + = ``(email, org)`` flips the border green; ``cancelled`` (bool: was the session persisted?) + renders the calm close; ``failed`` = ``(message, hint)`` flips the border red and shows the + failure INSIDE the same box (e.g. a wrong code). Legend ``◆ fp`` rides the top border.""" + if signed_in is not None: + border = theme.SUCCESS + elif failed is not None: + border = theme.ERROR + elif cancelled is not None: + border = theme.FAINT + else: + border = theme.ACCENT + lines: List[Any] = [] + if signed_in is None and cancelled is None: + intro = Text() + intro.append("welcome ", style=theme.TEXT_DIM) + intro.append("— ", style=theme.FAINT) + intro.append("sign in with a one-time code", style=theme.TEXT_DIM) + lines.append(intro) + for label, value in done: + ln = Text() + ln.append("✓ ", style=theme.SUCCESS) + ln.append(str(label), style=theme.TEXT_DIM) + if value: + ln.append(" ") + ln.append(str(value), style=theme.TEXT_DIM) + lines.append(ln) + if active is not None: + ln = Text() + ln.append("❯ ", style=f"bold {theme.ACCENT}") + ln.append(str(active), style=theme.TEXT) + ln.append(" ") + if active_slots: + ln.append_text(_login_slots(active_value, active_slots)) + else: + ln.append(active_value, style=f"bold {theme.TEXT}") + ln.append("▌", style=theme.ACCENT) # a static block cursor + lines.append(ln) + if error: + lines.append(Text(" " + str(error), style=theme.ERROR)) + elif helper: + lines.append(Text(" " + str(helper), style=theme.TEXT_DIM)) + if note: + lines.append(Text("· " + str(note), style=theme.FAINT)) + if inset is not None: + lines.append(inset) + if signed_in is not None: + email, org = signed_in + lines.append(Rule(style=theme.THIN_RULE)) + head = Text() + head.append("● ", style=theme.SUCCESS) + head.append("signed in", style=theme.TEXT) + lines.append(head) + lines.append(Text(str(email), style=theme.ACCENT)) + if org: + lines.append(Text(str(org), style=theme.TEXT_DIM)) + if cancelled is not None: + ln = Text() + ln.append("○ ", style=theme.FAINT) + if cancelled: # the session WAS saved — you're in, just no org yet + ln.append("signed in", style=theme.TEXT_DIM) + ln.append(" · pick an org with ", style=theme.FAINT) + ln.append("fp orgs switch", style=theme.TEXT_DIM) + else: + ln.append("cancelled — not signed in", style=theme.FAINT) + lines.append(ln) + if failed is not None: + msg, hint = failed + fl = Text() + fl.append("✗ ", style=f"bold {theme.ERROR}") + fl.append(str(msg), style=theme.TEXT) + lines.append(fl) + if hint: + h = Text(str(hint), style=theme.TEXT_DIM) + h.highlight_words(["fp login"], style=theme.ACCENT) # glow the command to run + lines.append(h) + legend = Text() + legend.append("◆ ", style=f"bold {border}") + legend.append("fp", style="bold white") + panel = Panel(Group(*lines), box=ROUNDED, border_style=border, title=legend, + title_align="left", padding=(0, 1), expand=False) + return Padding(panel, (0, 0, 0, 2)) + + +def _panel(mark: str, color: str, title: str, body: Text, hint: Optional[str] = None) -> None: + """The one boxed-notice renderer for the whole auth/outcome family — `mark` + `body` + in a `color`-bordered titled box, with an optional dim hint and any command refs + highlighted. Built from a `rich.Text` (literal body — no markup injection). The colour + carries the meaning: **green** = success, **brand-accent** = neutral 'already-in-state' + no-op, **red** = failure. One shape so they all read as one family.""" + text = Text() + indent = "" + # A box may be mark-less (the colour/title carry the meaning, e.g. the red + # `not signed in` box); then the body sits flush and the hint lines up under it. + if mark: + text.append(f"{mark} ", style=f"bold {color}") + indent = " " + text.append_text(body) + if hint: + text.append(f"\n{indent}") + text.append(hint, style="dim") + text.highlight_words( + ["fp login", "fp logout", "login --force"], style="bold cyan" + ) + panel = Panel( + text, + border_style=color, + title=f"[bold {color}]{title}[/]", + title_align="left", + expand=False, + padding=(0, 1), + ) + # A blank line above for breathing room, and a 2-space left indent so the box + # lines up with the rest of the indented chrome (`◆`/`❯`/`✓` are all at col 2) + # instead of sitting flush-left against it. + _stderr.print() + _stderr.print(Padding(panel, (0, 0, 0, 2))) + + +def _error_box(message: str, hint: Optional[str] = None) -> None: + """A red `✗` failure box — the chokepoint for every CLI error.""" + _panel("✗", "red", "error", Text(message), hint) + + +def _status_box(title: str, body: Text, hint: Optional[str] = None) -> None: + """A neutral **gray** `○` box for 'already in that state' auth no-ops (login when already + signed in, logout when already signed out) — gray, deliberately NOT the red error box.""" + _panel("○", _NEUTRAL, title, body, hint) + + +def version_banner(version: str) -> None: + """A small branded box for `fp version` — `◆ fp vX.Y.Z` in a brand-accent + box, on **stdout** (the version is the command's output). `--quiet` prints the bare + version so scripts still get a clean value; use `--json` for a machine-readable shape.""" + if _quiet: + _stdout.print(version) + return + body = Text() + body.append("◆ ", style=f"bold {_ACCENT}") + body.append("fp", style="bold") + body.append(" ") + body.append(f"v{version}", style=f"bold {_ACCENT}") + panel = Panel( + body, + border_style=_ACCENT, + title=f"[bold {_ACCENT}]version[/]", + title_align="left", + expand=False, + padding=(0, 1), + ) + _stdout.print() + _stdout.print(Padding(panel, (0, 0, 0, 2))) + + +# ── top-level help (the grouped `fp` / `fp help` / `fp --help` screen) ── +# Commands grouped by PURPOSE (auth/identity → read-only telemetry → mutating resources → +# utilities), each row a one-line "what it does" + a dim trailing subcommand/flag hint. One +# rounded ACCENT panel, the same shell as every other boxed view. + +# (group heading, [(command, one-line description, subcommand/flag hint)]) — fixed order. +_TOP_LEVEL_GROUPS = [ + ("ESSENTIALS", [ + ("version", "Show the CLI version.", ""), + ("help", "Show this help and the available commands.", ""), + ("login", "Sign in with an emailed one-time code.", ""), + ("logout", "Clear the saved session on this machine.", ""), + ("whoami", "Show the current user, active org, and perms.", ""), + ]), + ("OBSERVE", [ + ("events", "List the raw per-step agent event trail.", ""), + ("sessions", "List agent runs — one row per run.", ""), + ("evals", "List scored agent evaluations.", "--aggregate"), + ("errors", "List errored events.", "--aggregate"), + ("usage", "Show current org usage for the metering window.", ""), + ]), + ("ENFORCE", [ + ("policies", "Write cloud-managed policies.", "list show publish test compose enable disable delete"), + ("fleet", "Deploy policies to machines.", "list show deploy diff history rollback rename"), + ("guardrails", "What enforcement actually blocked.", "summary timeline"), + ]), + ("MANAGE", [ + ("orgs", "Switch and inspect the active org.", "list switch current perms"), + ("keys", "Provision and manage API keys.", "list show create update disable regenerate"), + ("users", "Manage org members and their permissions.", "list show create update disable enable"), + ("query", "Run and manage saved SQL queries.", "list show create update delete run schema"), + ("alerts", "Define, edit, and test alerts.", "list show create update delete test"), + # `context-*` rather than the three names spelled out: this hint column + # ellipsis-truncates at ~110 cols (see `desc_max` below) and this row was + # already overflowing, so the long form would never render. The full list + # is one level down, in `fp audits --help`, which walks the real + # Click tree. + ("audits", "Schedule audits and triage their findings.", "list show create edit delete run runs findings context-*"), + ("issues", "Triage and resolve issues.", "list count show ack assign resolve comment subscribe open"), + ("settings", "View and change org settings.", "list schema set"), + ]), + ("TOOLS", [ + ("list", "List distinct values behind the filter dropdowns.", ""), + ("agent", "Chat with the FailproofAI Cloud assistant.", "health models chats ask show rename delete"), + ]), +] + +# (example command, one-line purpose) — chosen to cover globals, commands, subcommands, flags, pipes. +_TOP_LEVEL_EXAMPLES = [ + ("fp --base-url https://dash.example.com login", "first run — set the dashboard URL, then sign in"), + ("fp --json sessions --since 24h", "a command + the global --json + an option"), + ("fp keys create ci-bot --permission-set read-only", "command → subcommand → options"), + ("fp evals --aggregate --since 7d --env prod", "a command flag (--aggregate) + filters"), + ("fp --json errors --since 24h | jq '.total'", "pipe JSON output into a script"), +] + + +def render_top_level_help() -> None: + """The grouped top-level help (stdout) for ``fp`` / ``fp help`` / ``fp + --help``: a short intro + globals line, the ``Commands · {n}`` panel (four purpose groups — + bold-white headings, BLUE command names, white one-line descriptions, FAINT subcommand hints, + a FAINT footer), then aligned EXAMPLES. Presentation only — same routing, same commands. + NO_COLOR keeps the box + bold headings + alignment, drops colour.""" + total = sum(len(cmds) for _, cmds in _TOP_LEVEL_GROUPS) + + def _bullet(*parts) -> Text: + line = Text(" · ", style=theme.FAINT) + for text, style in parts: + line.append(text, style=style) + return line + + # ── brand mark ── + _stdout.print() + _stdout.print(Text(" ◆ fp", style=f"bold {theme.ACCENT}")) + + # ── getting started ── + _stdout.print() + _stdout.print(Text(" GETTING STARTED", style="bold white")) + _stdout.print(_bullet(("sign in — the CLI points at ", theme.TEXT), + ("https://app.befailproof.ai", theme.TEXT_DIM), + (" by default: ", theme.TEXT), + ("fp login", theme.ACCENT))) + _stdout.print(Text(" (self-hosted or dev? add ", style=theme.FAINT) + + Text("--base-url https://your-dashboard", style=theme.TEXT_DIM) + + Text(" or set ", style=theme.FAINT) + + Text("FP_DASHBOARD_URL", style=theme.TEXT_DIM) + + Text("; saved after login.)", style=theme.FAINT)) + _stdout.print(_bullet(("sign in with the 6-digit code emailed to you. self-signed dashboard? add ", theme.TEXT), + ("--insecure", theme.ACCENT), (".", theme.TEXT))) + _stdout.print(_bullet(("then: ", theme.TEXT), ("fp whoami", theme.ACCENT), + (" ", theme.FAINT), ("fp --json sessions --since 24h", theme.ACCENT))) + + # ── global options (placement + the list + multi-tenant) ── + _stdout.print() + head = Text(" GLOBAL OPTIONS", style="bold white") + head.append(" — pass them BEFORE the command", style=theme.FAINT) + _stdout.print(head) + glob = Text(" ") + for i, g in enumerate(("--json", "--base-url", "--org", "--token", "--api-key", + "--insecure/--secure", "--timeout", "--quiet", "--no-color")): + if i: + glob.append(" · ", style=theme.FAINT) + glob.append(g, style=theme.TEXT_DIM) + _stdout.print(glob) + eg = Text(" e.g. ") + eg.append("fp --json events ", style=theme.TEXT_DIM) + eg.append("✓", style=theme.SUCCESS) + eg.append(" fp events --json ", style=theme.TEXT_DIM) + eg.append("✗", style=theme.ERROR) + _stdout.print(eg) + mt = Text(" multi-tenant: set the org at login (", style=theme.FAINT) + mt.append("login --org <slug>", style=theme.ACCENT) + mt.append(") or per command (", style=theme.FAINT) + mt.append("--org", style=theme.TEXT_DIM) + mt.append(" / ", style=theme.FAINT) + mt.append("FP_ORG", style=theme.TEXT_DIM) + mt.append(").", style=theme.FAINT) + _stdout.print(mt) + ci = Text(" in CI: authenticate with ", style=theme.FAINT) + ci.append("--api-key", style=theme.TEXT_DIM) + ci.append(" / ", style=theme.FAINT) + ci.append("FP_API_KEY", style=theme.TEXT_DIM) + ci.append(" instead of a session (", style=theme.FAINT) + ci.append("login", style=theme.TEXT_DIM) + ci.append(", ", style=theme.FAINT) + ci.append("orgs", style=theme.TEXT_DIM) + ci.append(" and ", style=theme.FAINT) + ci.append("agent", style=theme.TEXT_DIM) + ci.append(" then exit 2).", style=theme.FAINT) + _stdout.print(ci) + + # ── the Commands panel (one table; group headings are full-width rows in col 0) ── + name_w = max(max(len(c) for c, _, _ in cmds) for _, cmds in _TOP_LEVEL_GROUPS) + name_w = max(name_w, max(len(h) for h, _ in _TOP_LEVEL_GROUPS)) # headings sit in the same column + desc_max = max(SCORES_MIN_WIDTH, min(_stdout.width, 110) - name_w - 8) # cap so the panel fits; hints ellipsis-truncate + table = Table(box=None, show_header=False, pad_edge=False, padding=(0, 2, 0, 0)) + table.add_column(no_wrap=True, width=name_w) + table.add_column(no_wrap=True, overflow="ellipsis", max_width=desc_max) + for gi, (heading, cmds) in enumerate(_TOP_LEVEL_GROUPS): + if gi: + table.add_row("", "") # blank line between groups + table.add_row(Text(heading, style="bold white"), Text("")) + for name, desc, hint in cmds: + cell = Text(desc, style=theme.TEXT) + if hint: + cell.append(" · ", style=theme.FAINT) + cell.append(hint, style=theme.FAINT) + table.add_row(Text(name, style=theme.BLUE), cell) + footer = Text() + footer.append("run ", style=theme.FAINT) + footer.append("fp <command> --help", style=theme.TEXT_DIM) + footer.append(" for a command's subcommands and flags", style=theme.FAINT) + title = Text() + title.append("Commands", style="bold white") + title.append(" · ", style=theme.FAINT) + title.append(str(total), style=theme.TEXT_DIM) + panel = Panel(Group(table, Text(""), footer), box=ROUNDED, border_style=theme.ACCENT, + title=title, title_align="left", padding=(0, 1), expand=False) + _stdout.print() + _stdout.print(Padding(panel, (0, 0, 0, 2))) + + # ── examples (aligned, below the panel) ── + _stdout.print() + _stdout.print(Text(" EXAMPLES", style="bold white")) + ex = Table(box=None, show_header=False, pad_edge=False, padding=(0, 3, 0, 0)) + ex.add_column(no_wrap=True) + ex.add_column(no_wrap=True, overflow="ellipsis", max_width=max(SCORES_MIN_WIDTH, min(_stdout.width, 110) - 56)) + for cmd, why in _TOP_LEVEL_EXAMPLES: + ex.add_row(Text(cmd, style=theme.ACCENT), Text(why, style=theme.TEXT_DIM)) + _stdout.print(Padding(ex, (0, 0, 0, 2))) + + _stdout.print() + + +def already_signed_out() -> None: + """`logout` with no active session — a calm, neutral **gray** box (NOT an error; you + wanted out, you're out). The command still exits 0.""" + if _quiet: + return + _status_box( + "already signed out", + Text("no active session"), + hint="run fp login to sign in", + ) + + +def not_signed_in() -> None: + """`whoami` with no session — a **red** 'not signed in' box (this IS an error: you asked + who you are, and the answer is nobody). whoami still exits 0; presentation only. The red + title carries the meaning, so the body is mark-less.""" + if _quiet: + return + _panel( + "", + "red", + "not signed in", + Text("you're not logged in right now"), + hint="run fp login to sign in", + ) + + +def render_key_mode_whoami(active_org: Optional[str]) -> None: + """`whoami` under an API key — the neutral gray box, NOT the red "not signed in" one. + + You are authenticated; you are simply not a *user*, so there is no identity, no + membership list and no permission panel to show. The org line is the part worth + reading: blank means no `--org` was given, and an instance-scoped key with no org + silently resolves to the deployment's default one. + """ + if _quiet: + return + body = Text("authenticated with an API key — no user session") + # 3 spaces: `_status_box`'s mark is `○` + 2 spaces, so continuation lines and the + # hint line up under the first character of the body, like every other box here. + body.append("\n ") + body.append(f"org: {active_org}" if active_org else "org: not specified") + _status_box( + "api-key mode", + body, + hint=None if active_org else "pass --org <slug> if the key serves more than one org", + ) + + +def cli_error(message: str, hint: Optional[str] = None) -> None: + """The single chokepoint for every CLI failure (auth/forbidden/not-found/usage/api), + so they all share one prominent red box. Always shown (errors ignore --quiet).""" + _error_box(message, hint) + + +def user_banner(email: str, is_instance_admin: bool = False) -> None: + """A one-line 'who you are' header above the org list.""" + if _quiet: + return + tag = " [dim]· instance admin[/]" if is_instance_admin else "" + _stderr.print() + _stderr.print(f" [bold {_ACCENT}]◆[/] [bold]{email}[/]{tag}") + + +# ── whoami (logged-in view) — presentation only ───────────────────────────── + + +# Resource display order for the grouped permissions panel (then alphabetical for the rest). +_PERM_RESOURCE_PRIORITY = ["dashboards", "keys", "queries", "users", "issues", "alerts", + "settings", "evaluations", "events", "agent"] + + +def _perm_res_key(r: str): + return (_PERM_RESOURCE_PRIORITY.index(r) if r in _PERM_RESOURCE_PRIORITY + else len(_PERM_RESOURCE_PRIORITY), r) + + +def _perm_act_key(a: str): + """Action sort key: risk order (read → modify → invoke → destroy), then alphabetical.""" + return (theme.PERM_RANK.get(theme.perm_color(a), 99), a) + + +def _group_permissions(perms: Sequence[str]): + """Group a flat permission list (``dashboards:read`` …) by resource → a list of + ``(resource, [(action, color), …])``. Resources follow a fixed priority order (then + alphabetical); actions within a row follow risk order (read → modify → invoke → destroy), + then alphabetical. Unknown actions get the neutral default color (never crash).""" + by_resource: dict = {} + for p in perms: + resource, _, action = str(p).partition(":") + by_resource.setdefault(resource, []).append(action) + grouped = [] + for resource in sorted(by_resource, key=_perm_res_key): + actions = sorted(set(by_resource[resource]), key=_perm_act_key) + grouped.append((resource, [(a, theme.perm_color(a)) for a in actions])) + return grouped + + +# Diff highlight backgrounds (subtle, match the keys secret-box green chip): added grants get +# a green chip, removed grants a red chip + strikethrough. Mirror the dashboard's git-style diff. +_DIFF_ADDED_STYLE = f"{theme.SUCCESS} on #13211c" +_DIFF_REMOVED_STYLE = f"{theme.ERROR} on #241516 strike" + + +def _group_permissions_diff(union: Sequence[str], added: set, removed: set): + """Group the UNION of before+after grants by resource → ``(resource, [(action, state), …])`` + where ``state`` ∈ {``added``, ``removed``, ``unchanged``}. Kept actions (added/unchanged) + are risk-ordered first; removed actions (ghosts) sort to the end of their row.""" + by_resource: dict = {} + for p in union: + resource, _, action = str(p).partition(":") + by_resource.setdefault(resource, []).append(action) + grouped = [] + for resource in sorted(by_resource, key=_perm_res_key): + actions = sorted(set(by_resource[resource]), key=_perm_act_key) + kept = [a for a in actions if f"{resource}:{a}" not in removed] + gone = [a for a in actions if f"{resource}:{a}" in removed] + states = [] + for a in kept + gone: # removed (struck ghosts) at the end of the row + perm = f"{resource}:{a}" + st = "added" if perm in added else ("removed" if perm in removed else "unchanged") + states.append((a, st)) + grouped.append((resource, states)) + return grouped + + +def render_permissions_panel( + permissions: Sequence[str], + *, + active_org: Optional[str] = None, + suffix: Optional[str] = None, + diff: Optional[dict] = None, +): + """The grouped, risk-coloured permissions panel — the ONE renderer shared by ``whoami``, + ``orgs perms``, ``users show``/``create``/``update``, and ``keys create``/``update``. Rounded + ACCENT panel titled ``permissions · {n}`` (+ ``· {active_org}`` / ``· {suffix}`` when given); + one row per resource (`_group_permissions` ordering), each action coloured by the action→risk + map; under NO_COLOR destructive actions get a ``*``. Returns the padded renderable so the + caller prints it (so every call site can never drift). + + With ``diff={"added": [...], "removed": [...]}`` the ``permissions`` arg is the UNION of the + before+after grants and each action renders by its diff state: added = green chip, removed = + red chip + strikethrough (a ghost), unchanged = dim. The title count is the NEW set size + (added + unchanged; the struck removals are not counted). NO_COLOR falls back to ``+``/``-`` + prefixes since the chip colours/strikethrough don't render.""" + perm_table = Table(box=None, pad_edge=False, show_header=False) + perm_table.add_column(style=theme.TEXT_DIM, no_wrap=True) # resource + perm_table.add_column() # actions + + if diff is not None: + added, removed = set(diff.get("added") or []), set(diff.get("removed") or []) + for resource, states in _group_permissions_diff(permissions, added, removed): + acts = Text() + for i, (action, st) in enumerate(states): + if i: + acts.append(" ") + if st == "added": + acts.append(f"+{action}" if _no_color else f" {action} ", style=_DIFF_ADDED_STYLE) + elif st == "removed": + acts.append(f"-{action}" if _no_color else f" {action} ", style=_DIFF_REMOVED_STYLE) + else: + acts.append(action, style=theme.TEXT_DIM) + perm_table.add_row(resource, acts) + title_count = len([p for p in permissions if p not in removed]) + else: + for resource, actions in _group_permissions(permissions): + acts = Text() + for i, (action, color) in enumerate(actions): + if i: + acts.append(" ") + label = action + ("*" if (_no_color and color == theme.PERM_DANGER) else "") + acts.append(label, style=color) + perm_table.add_row(resource, acts) + title_count = len(permissions) + + if perm_table.row_count == 0: # 0-perm user / non-member org → a calm row, not an empty box + perm_table.add_row("", Text("(no permissions)", style=theme.FAINT)) + + title = Text() + title.append("permissions", style=f"bold {theme.ACCENT}") + title.append(f" · {title_count}", style=theme.LABEL) + if active_org: + # whoami passes the active org's NAME here; render it in glowing white so the + # "whose permissions" context stands out. + title.append(" · ", style=theme.LABEL) + title.append(active_org, style="bold white") + if suffix: + title.append(f" · {suffix}", style=theme.LABEL) + return Padding(Panel(perm_table, box=ROUNDED, border_style=theme.ACCENT, title=title, + title_align="left", padding=(0, 1), expand=False), (0, 0, 0, 2)) + + +def perm_diff_legend() -> Text: + """A dim legend under the update diff panel: ``added removed(struck) unchanged`` in their + own diff styles (NO_COLOR keeps the words readable).""" + t = Text(" ") + if _no_color: + t.append("+added", style=theme.SUCCESS) + t.append(" ") + t.append("-removed", style=theme.ERROR) + t.append(" ") + t.append("unchanged", style=theme.TEXT_DIM) + return t + t.append(" added ", style=_DIFF_ADDED_STYLE) + t.append(" ") + t.append(" removed ", style=_DIFF_REMOVED_STYLE) + t.append(" ") + t.append("unchanged", style=theme.TEXT_DIM) + return t + + +def render_orgs_panel(orgs: Sequence[dict]): + """The ``your orgs · {n}`` panel — the ONE renderer shared by ``whoami`` and ``orgs list``. + Marker ``●`` (active) / ``○`` (other), columns org/name/role/perms, and a ``switch with …`` + line when there are other orgs. Returns the padded renderable so the caller prints it.""" + # Column headers in white (brighter than the dim data) so they read as labels. + org_table = Table(box=None, pad_edge=False, show_header=True, header_style=theme.TEXT) + org_table.add_column(" ") # marker + for col in ("org", "name", "role", "perms"): + org_table.add_column(col) + for o in orgs: + is_active = o["is_active"] + marker = Text("●", style=theme.ACCENT) if is_active else Text("○", style=theme.FAINT) + org_table.add_row( + marker, + # the ACTIVE org's slug glows white (the org column); others stay dim. + Text(o["slug"], style="bold white" if is_active else theme.TEXT_DIM), + Text(o["name"], style=theme.TEXT_DIM), + Text(o["role"], style=theme.LABEL), + Text(str(o["perms"]), style=theme.LABEL), + ) + others = [o["slug"] for o in orgs if not o["is_active"]] + if others: + switch = Text() + switch.append("switch with ", style=theme.FAINT) + switch.append("fp orgs switch <slug>", style=theme.ACCENT) + body: Any = Group(org_table, Text(), switch) + else: + body = org_table + title = Text() + title.append("your orgs", style=f"bold {theme.ACCENT}") + title.append(f" · {len(orgs)}", style=theme.LABEL) + return Padding(Panel(body, box=ROUNDED, border_style=theme.ACCENT, title=title, + title_align="left", padding=(0, 1), expand=False), (0, 0, 0, 2)) + + +def render_whoami( + *, + email: str, + is_instance_admin: bool, + user_id: str, + active_org: Optional[str], + active_role: Optional[str], + permissions: Sequence[str], + orgs: Sequence[dict], +) -> None: + """The logged-in ``whoami`` view (stdout): an identity header, then the shared + permissions + orgs panels. Presentation only; no legend.""" + c = _stdout + + # ── identity header (no box) ── + c.print() + head = Text(" ") + head.append("◆ ", style=theme.ACCENT) + head.append(email, style=f"bold {theme.TEXT}") + if is_instance_admin: + head.append(" · ", style=theme.FAINT) + head.append("instance admin", style=theme.LABEL) + c.print(head) + + lw = len("active") # align the id / active labels in one fixed column + id_line = Text(" ") + id_line.append("id".ljust(lw), style=theme.LABEL) + id_line.append(" ") + id_line.append(user_id, style=theme.TEXT_DIM) + c.print(id_line) + + active_line = Text(" ") + active_line.append("active".ljust(lw), style=theme.LABEL) + active_line.append(" ") + if active_org: + active_line.append(active_org, style=f"bold {theme.ACCENT}") + if active_role: + active_line.append(" · ", style=theme.FAINT) + active_line.append(active_role, style=theme.TEXT) + else: + active_line.append("(none)", style=theme.TEXT_DIM) + c.print(active_line) + + # The permissions panel is titled with the active org's NAME (e.g. "Globex Corp"), + # falling back to the slug if the membership has no name. + active_org_name = next((o["name"] for o in orgs if o.get("is_active") and o.get("name")), active_org) + c.print() + c.print(render_permissions_panel(permissions, active_org=active_org_name)) + c.print() + c.print(render_orgs_panel(orgs)) + c.print() + + +def render_orgs_list(orgs: Sequence[dict]) -> None: + """``orgs list`` (stdout): just the shared ``your orgs`` panel.""" + _stdout.print() + _stdout.print(render_orgs_panel(orgs)) + _stdout.print() + + +def render_current_org(*, slug: str, name: Optional[str], role: str, + permission_count: int, email: str) -> None: + """``orgs current`` (stdout): a compact ``current org`` identity card — slug (ACCENT) + + name on line 1, role + permission count on line 2, signed-in email on line 3 — with a + dim footer (stderr) cross-linking ``orgs perms`` and ``orgs switch``.""" + line1 = Text() + line1.append(slug, style=f"bold {theme.ACCENT}") + if name: + line1.append(" ") + line1.append(name, style=theme.TEXT_DIM) + line2 = Text() + line2.append("role ", style=theme.LABEL) + line2.append(role, style=theme.TEXT) + line2.append(" · ", style=theme.FAINT) + line2.append(f"{permission_count} permissions", style=theme.LABEL) + line3 = Text() + line3.append("signed in as ", style=theme.LABEL) + line3.append(email, style=theme.TEXT_DIM) + + panel = Panel(Group(line1, line2, line3), box=ROUNDED, border_style=theme.ACCENT, + title=Text("current org", style=f"bold {theme.ACCENT}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print() + _stdout.print(Padding(panel, (0, 0, 0, 2))) + if not _quiet: + foot = Text(" ") + foot.append("see permissions with ", style=theme.FAINT) + foot.append("fp orgs perms", style=theme.ACCENT) + foot.append(" · ", style=theme.FAINT) + foot.append("switch with ", style=theme.FAINT) + foot.append("fp orgs switch <slug>", style=theme.ACCENT) + _stderr.print(foot) + _stderr.print() + + +def render_org_perms(*, slug: str, role: str, permissions: Sequence[str], + name: Optional[str] = None) -> None: + """``orgs perms`` (stdout): an identity header line + the shared permissions panel. + + The header leads with the org **name** (glowing accent) then the slug handle and your role; + the permission count moves into the panel's border title (``permissions · {n} · {name}``), + so the header reads as a clean identity line instead of repeating the count.""" + head = Text(" ") + head.append("◆ ", style=theme.ACCENT) + head.append(name or slug, style=f"bold {theme.ACCENT}") # org name — the identity, glowing + if name and name != slug: + head.append(" · ", style=theme.FAINT) + head.append(slug, style=theme.TEXT_DIM) # the slug handle, dim + head.append(" · ", style=theme.FAINT) + head.append("role ", style=theme.LABEL) + head.append(role, style=theme.TEXT) + _stdout.print() + _stdout.print(head) + _stdout.print() + # The org NAME rides in the panel's border title (permissions · {n} · {name}), glowing white. + _stdout.print(render_permissions_panel(permissions, active_org=name or slug)) + _stdout.print() + + +# ── orgs switch — interactive picker + switched card (presentation only) ───── +# The picker frame and the switched card share the same rounded Panel shell as every other +# boxed view (only the border colour + contents differ), so they read as one system. + +_PICKER_FOOTER = "↑↓ move · ⏎ select · esc cancel" + + +def org_picker_frame(orgs: Sequence[dict], idx: int): + """One frame of the interactive ``orgs switch`` picker (a renderable, redrawn in place by the + caller's ``Live``): a rounded ACCENT panel ``switch org · {n} available``; one row per org with + a ``❯`` pointer (ACCENT) on row ``idx`` — the selected slug bright (TEXT), others dim — and a + ``● current`` (SUCCESS) / ``○`` (FAINT) status. Footer ``↑↓ move · ⏎ select · esc cancel`` in + the bottom border. NO_COLOR keeps the box, pointer, and ●/○ shapes (colour drops out).""" + table = Table(box=None, pad_edge=False, show_header=False, padding=(0, 2, 0, 0)) + table.add_column(no_wrap=True) # pointer + table.add_column(no_wrap=True) # slug + table.add_column(no_wrap=True) # status + for i, o in enumerate(orgs): + selected = i == idx + ptr = Text("❯", style=f"bold {theme.ACCENT}") if selected else Text(" ") + slug = Text(o["slug"], style=theme.TEXT if selected else theme.TEXT_DIM) + status = (Text("● current", style=theme.SUCCESS) if o.get("is_current") + else Text("○", style=theme.FAINT)) + table.add_row(ptr, slug, status) + title = Text() + title.append("switch org", style=f"bold {theme.TEXT}") + title.append(" · ", style=theme.FAINT) + title.append(f"{len(orgs)} available", style=theme.TEXT_DIM) + panel = Panel(table, box=ROUNDED, border_style=theme.ACCENT, title=title, title_align="left", + padding=(0, 1), expand=False) + # The key hint rides as a FAINT line just beneath the panel (always fits — no border + # truncation of the wide ⏎/arrow glyphs), aligned under the box. + footer = Text(" " + _PICKER_FOOTER, style=theme.FAINT) + return Group(Padding(panel, (0, 0, 0, 2)), footer) + + +def render_org_picker_numbered(orgs: Sequence[dict], *, current: Optional[str] = None) -> None: + """Non-TTY fallback for ``orgs switch`` (stderr): the same boxed panel as the live picker, but + numbered (``# · slug · status``) for a typed choice. Printed once above the prompt.""" + if _quiet: + return + table = Table(box=SIMPLE_HEAD, border_style=theme.THIN_RULE, pad_edge=False, show_edge=False, + show_header=True, header_style="bold white", padding=(0, 2, 0, 0)) + for col in ("#", "org", "status"): + table.add_column(col, no_wrap=True) + table.add_row("", "", "") + for i, o in enumerate(orgs, 1): + status = (Text("● current", style=theme.SUCCESS) if o.get("slug") == current + else Text("○", style=theme.FAINT)) + table.add_row(Text(str(i), style=theme.ACCENT), Text(o["slug"], style=theme.TEXT), status) + title = Text() + title.append("switch org", style=f"bold {theme.ACCENT}") + title.append(f" · {len(orgs)} available", style=theme.LABEL) + _stderr.print() + _stderr.print(Padding(Panel(table, box=ROUNDED, border_style=theme.ACCENT, title=title, + title_align="left", padding=(0, 1), expand=False), (0, 0, 0, 2))) + + +def render_switched_org(*, slug: str, prev_slug: Optional[str] = None, + perm_count: Optional[int] = None) -> None: + """The ``orgs switch`` success card (stderr): a GREEN-bordered ``switched org`` card — a + hairline under the title, a hero ``● {slug}`` (SUCCESS dot + ACCENT slug), and a meta line + ``was {prev} · {n} permissions``. The green border IS the success signal (no tick, no + 'successfully'). The same box shell as the picker — only the border colour + contents differ.""" + if _quiet: + return + hero = Text() + hero.append("● ", style=theme.SUCCESS) + hero.append(slug, style=f"bold {theme.ACCENT}") + meta = Text() + if prev_slug: + meta.append("was ", style=theme.TEXT_DIM) + meta.append(prev_slug, style=theme.TEXT_DIM) + if perm_count is not None: + if prev_slug: + meta.append(" · ", style=theme.FAINT) + meta.append(f"{perm_count} permissions", style=theme.TEXT_DIM) + parts = [Rule(style=theme.THIN_RULE), hero] + if meta.plain: + parts.append(meta) + card = Panel(Group(*parts), box=ROUNDED, border_style=theme.SUCCESS, + title=Text("switched org", style=f"bold {theme.TEXT}"), title_align="left", + padding=(0, 1), expand=False, width=min(max(_stderr.width - 4, 36), 60)) + _stderr.print() + _stderr.print(Padding(card, (0, 0, 0, 2))) + + +def org_already_on(slug: str) -> None: + """Calm no-op for selecting the org you're already on (stderr): ``○ already on {slug} — + nothing changed`` (calm ``○``, dim body — not a ``✓``, which would read as an action). A + single line, not the card.""" + if _quiet: + return + _stderr.print() + _plain(("○ ", theme.TEXT_DIM), ("already on ", theme.TEXT_DIM), + (slug, f"bold {theme.ACCENT}"), (" — nothing changed", theme.TEXT_DIM)) + + +def org_only_one(slug: str) -> None: + """Calm line when the account has a single org (stderr): ``only org · {slug}. nothing to + switch to.``""" + if _quiet: + return + _stderr.print() + _plain(("only org · ", theme.TEXT_DIM), (slug, f"bold {theme.ACCENT}"), + (". nothing to switch to.", theme.TEXT_DIM)) + + +def org_switch_cancelled(current: Optional[str]) -> None: + """Calm cancel line for an Esc/Ctrl-C mid-pick (stderr): ``○ cancelled — still on {current}`` + (FAINT). Declining is a good outcome, not an error.""" + if _quiet: + return + _stderr.print() + if current: + _plain(("○ ", theme.FAINT), ("cancelled — still on ", theme.LABEL), (current, theme.LABEL)) + else: + _plain(("○ ", theme.FAINT), ("cancelled — nothing changed", theme.LABEL)) + + +def org_none_available(*, instance_admin: bool) -> None: + """Calm empty-state when you belong to no org (stderr): ``no orgs available`` + an admin hint.""" + if _quiet: + return + _stderr.print() + _plain(("no orgs available", theme.TEXT_DIM)) + if instance_admin: + _plain(("instance admin — pass a slug: ", theme.FAINT), ("fp orgs switch <slug>", theme.ACCENT)) + + +def org_not_found(slug: str, *, suggestion: Optional[str] = None) -> None: + """Not-found error for ``orgs switch <slug>`` (stderr, always shown): ``✗ no org named {slug}`` + + an optional ``did you mean {match}?`` + a ``run fp orgs switch to pick`` hint.""" + _stderr.print() + _plain(("✗ ", f"bold {theme.ERROR}"), ("no org named ", theme.TEXT), (slug, f"bold {theme.ACCENT}")) + hint = Text(" ") + if suggestion: + hint.append("did you mean ", theme.FAINT) + hint.append(suggestion, style=theme.ACCENT) + hint.append("? · ", style=theme.FAINT) + else: + hint.append("", style=theme.FAINT) + hint.append("run ", style=theme.FAINT) + hint.append("fp orgs switch", style=theme.ACCENT) + hint.append(" to pick", style=theme.FAINT) + _stderr.print(hint) + + +# ── list panels (events / sessions) — presentation only ───────────────────── + +# Score colour bands (named so they're easy to tune) — ONE scale used everywhere a score +# is coloured (evals score cells, the aggregate avg + bar): ≥ GOOD cyan-green, ≥ OK amber, +# below OK red. Unified on .80/.50 (was .85/.70) so a score reads the same colour CLI-wide. +SCORE_GOOD = 0.80 +SCORE_OK = 0.50 +# The scores column is width-aware: pairs are fitted into a per-render budget (and capped +# there via the column max_width) so the fixed columns keep their natural width and the rest +# of the pairs collapse to `+N`. SCORES_MIN_WIDTH floors the budget on a narrow terminal. +SCORES_MIN_WIDTH = 8 +# Chrome reserved when sizing the scores budget (deliberately generous): the panel +# border/padding + left indent + the inter-column padding, plus headroom for Rich's own +# layout rounding. Over-reserving costs a few scores chars but keeps time/status intact. +_LIST_CHROME = 2 * 6 + 12 + +# Run/job states → colour. A small, stable enum, so a value→colour map is safe; +# anything unknown falls back to neutral dim (never crash on a new state). +_STATUS_COLORS = { + "done": theme.SUCCESS, "completed": theme.SUCCESS, "passed": theme.SUCCESS, + "running": theme.AMBER, "queued": theme.AMBER, "pending": theme.AMBER, + "failed": theme.ERROR, "error": theme.ERROR, "cancelled": theme.ERROR, "timeout": theme.ERROR, +} + + +def _parse_iso(ts: str) -> Optional[datetime]: + """Tolerant ISO-8601 parse → datetime, or None if it doesn't parse (e.g. an + opaque/empty ts). Never raises — the caller falls back to the raw string.""" + s = (ts or "").strip() + if not s: + return None + if s.endswith("Z"): + s = s[:-1] + "+00:00" + try: + return datetime.fromisoformat(s) + except ValueError: + return None + + +def _row_times(timestamps: Sequence[Optional[datetime]]): + """Format a column of parsed timestamps for a list panel → ``(cells, days)``. + + Rows show clock time (`HH:MM:SS`) with the shared date carried in the panel title; + if the rows span more than one UTC day the date is folded back into each cell + (`MM-DD HH:MM:SS`). An unparsed slot yields ``None`` (the caller substitutes raw).""" + days = {dt.date() for dt in timestamps if dt is not None} + fmt = "%m-%d %H:%M:%S" if len(days) > 1 else "%H:%M:%S" + cells = [dt.strftime(fmt) if dt is not None else None for dt in timestamps] + return cells, days + + +def _panel_title(name: str, count: int, order: Optional[str], days) -> Text: + title = Text() + title.append(name, style=f"bold {theme.ACCENT}") + title.append(f" · {count}", style=theme.LABEL) + title.append(f" · {'oldest first' if order == 'asc' else 'newest first'}", style=theme.LABEL) + if days: + span = f"{min(days)} → {max(days)}" if len(days) > 1 else str(next(iter(days))) + title.append(f" · {span}", style=theme.LABEL) + return title + + +def render_list_panel( + name: str, + *, + header: Sequence[str], + rows: Sequence[Sequence[Text]], + days, + order: Optional[str], + empty_message: str, + last_col: Optional[str] = None, + last_col_max: Optional[int] = None, + title: Optional[Text] = None, + border: str = theme.ACCENT, + rule: str = theme.THIN_RULE, +) -> None: + """Shared boxed-list renderer for `events`/`sessions`/`evals`/`errors` (stdout). A rounded + panel (``border``, default ACCENT) titled ``{name} · {n} · {dir} · {date}`` (or an explicit + ``title`` override, e.g. the aggregate score-stats / error panels) wrapping a borderless + table with a bold-white header and a thin ``rule`` beneath it. ``rows`` are pre-styled cells + (per-column colour is the caller's job); ``days`` drives the title date/span. ``last_col`` + controls the final column's overflow: ``"ellipsis"`` truncates with `…` (capped to + ``last_col_max`` so it can't squeeze the fixed columns), ``"wrap"`` folds, ``None`` plain.""" + if not rows: + body: Any = Text(empty_message, style=theme.TEXT_DIM) + else: + # Bold bright-white headers so the column labels glow against the dim rows (the + # border tint lives in the panel; the thin rule separates header from data). + table = Table(box=SIMPLE_HEAD, border_style=rule, pad_edge=False, + show_edge=False, show_header=True, header_style="bold white", + expand=False, padding=(0, 2, 0, 0)) + last = len(header) - 1 + for i, col in enumerate(header): + if last_col and i == last: + if last_col == "wrap": + table.add_column(col, no_wrap=False, overflow="fold") + else: + table.add_column(col, no_wrap=True, overflow="ellipsis", max_width=last_col_max) + else: + table.add_column(col, no_wrap=True) + table.add_row(*([""] * len(header))) # a blank line between the header rule and the rows + for row in rows: + table.add_row(*row) + body = table + + panel = Panel(body, box=ROUNDED, border_style=border, + title=title if title is not None else _panel_title(name, len(rows), order, days), + title_align="left", padding=(0, 1), expand=False) + _stdout.print() + _stdout.print(Padding(panel, (0, 0, 0, 2))) + + +def render_events(items: Sequence[Any], *, order: Optional[str] = None, + empty_message: str = "no events in this window") -> None: + """The default ``events`` view: columns ``time · type · env · agent · session`` in the + shared list panel. Presentation only — ``--json``/``--fields`` never reach here. When the + set is empty the caller passes a filter-aware ``empty_message`` (e.g. ``no events match + these filters`` when filters are active vs the default ``no events in this window``).""" + tcells, days = _row_times([_parse_iso(getattr(e, "ts", "")) for e in items]) + rows = [] + for e, t in zip(items, tcells): + rows.append([ + Text(t if t is not None else (getattr(e, "ts", "") or "-"), style=theme.TEXT), + Text(getattr(e, "event_type", "") or "-", style=theme.TEXT), + Text(getattr(e, "environment", "") or "-", style=theme.TEXT_DIM), + Text(getattr(e, "agent_id", "") or "-", style=theme.TEXT_DIM), + Text(getattr(e, "session_id", "") or "-", style=theme.TEXT_DIM), + ]) + render_list_panel("events", header=["time", "type", "env", "agent", "session"], + rows=rows, days=days, order=order, empty_message=empty_message) + + +def _short_session(sid: str, *, full: bool = False) -> str: + """Truncate a long session id for the table (full id always kept in ``--json``): + ``sess-20260615-fcf97e01`` → ``sess-…fcf97e01`` (prefix + last 8); other long ids → + first 6 + ``…`` + last 6; short ids are left intact.""" + if full or not sid or sid == "-": + return sid + if sid.startswith("sess-") and len(sid) > 14: + return "sess-…" + sid[-8:] + if len(sid) > 18: + return sid[:6] + "…" + sid[-6:] + return sid + + +def _fmt_score_num(f: float) -> str: + """Compact score number: ``0.94`` → ``.94``, ``0.00`` → ``.00``, ``1.00`` → ``1.0``.""" + s = f"{f:.2f}" + if s == "1.00": + return "1.0" + if s.startswith("0."): + return s[1:] + if s.startswith("-0."): + return "-" + s[2:] + return s + + +def _score_color(f: float) -> str: + """A numeric score → its band colour: ≥.80 cyan-green, .50–.80 amber, <.50 red. + Higher == better for every metric (the CLI has no per-metric direction flag), so an + inverted metric like a raw `toxicity` would read 'backwards' — documented, not special-cased.""" + return theme.SCORE_HIGH if f >= SCORE_GOOD else (theme.AMBER if f >= SCORE_OK else theme.ERROR) + + +def _fmt_avg(v: float) -> str: + """Aggregate avg format: 2 decimals, leading zero KEPT (`0.71`), `1.00` → `1.0`.""" + s = f"{v:.2f}" + return "1.0" if s == "1.00" else s + + +def _score_value(value: Any): + """A score value → ``(display, colour)``. Numeric values colour by the GOOD/OK + thresholds; non-numeric values use a pass/fail substring rule. Under NO_COLOR a + failing numeric value (< OK) gets a trailing ``!`` so it stays visible without colour.""" + try: + f = float(value) + except (TypeError, ValueError): + low = str(value).lower() + if "fail" in low or "error" in low: + return str(value), theme.ERROR + if "pass" in low or "ok" in low or "true" in low: + return str(value), theme.SUCCESS + return str(value), theme.TEXT_DIM + color = _score_color(f) + label = _fmt_score_num(f) + if _no_color and f < SCORE_OK: + label += "!" + return label, color + + +def _scores_cell(scores: Optional[dict], *, budget: Optional[int] = None, full: bool = False) -> Text: + """Render a score map as ``metric value`` pairs (metric dim, value colour-coded). With a + width ``budget`` the cell greedily fits as many pairs as the budget allows (always at + least one) then appends ``+N`` for the rest — so an eval stays exactly one row and the + fixed columns never get squeezed. ``full`` (or no budget) shows every pair (and may wrap). + No ``=`` — pairs sit two spaces apart.""" + if not scores: + return Text("-", style=theme.TEXT_DIM) + rendered = [(str(k), *_score_value(v)) for k, v in scores.items()] # (metric, label, colour) + + if full or budget is None: + shown, extra = rendered, 0 + else: + shown, used = [], 0 + for i, (metric, label, _c) in enumerate(rendered): + w = (2 if shown else 0) + len(metric) + 1 + len(label) + leftover = len(rendered) - (len(shown) + 1) + suffix = (2 + 1 + len(str(leftover))) if leftover > 0 else 0 # room for " +N" + if shown and used + w + suffix > budget: + break + shown.append(rendered[i]) + used += w + extra = len(rendered) - len(shown) + + txt = Text() + for i, (metric, label, color) in enumerate(shown): + if i: + txt.append(" ") + txt.append(metric, style=theme.TEXT_DIM) + txt.append(" ") + txt.append(label, style=color) + if extra > 0: + txt.append(" ") + txt.append(f"+{extra}", style=theme.LABEL) + return txt + + +def _status_cell(status: str) -> Text: + return Text(status or "-", style=_STATUS_COLORS.get(str(status).lower(), theme.TEXT_DIM)) + + +def _eval_row_columns(items, *, full_ids): + """The five fixed columns (time/env/agent/session/status) shared by the sessions and + evals list views, as plain strings + the parsed ``days`` for the panel title.""" + tcells, days = _row_times( + [_parse_iso(getattr(e, "completed_at", "") or getattr(e, "created_at", "")) for e in items] + ) + times = [t if t is not None else (getattr(e, "completed_at", "") or "-") for e, t in zip(items, tcells)] + envs = [getattr(e, "environment", "") or "-" for e in items] + agents = [getattr(e, "agent_id", "") or "-" for e in items] + sessions = [_short_session(getattr(e, "session_id", "") or "-", full=full_ids) for e in items] + statuses = [getattr(e, "status", "") or "-" for e in items] + return times, envs, agents, sessions, statuses, days + + +def _session_agents(e: Any) -> list: + """The session's agent roster: a list of ``{"agent_id", "event_count"}`` dicts, server-sorted + by event count desc. Empty when the field is absent (older server) — so single-agent sessions + and legacy responses both fall through to the plain one-name rendering.""" + raw = getattr(e, "agents", None) or [] + return [a for a in raw if isinstance(a, dict)] + + +def is_multi_agent(e: Any) -> bool: + """True when more than one agent ran in the session — drives the ``+N`` badge and the + sessions footer's ``multi-agent`` count.""" + return len(_session_agents(e)) > 1 + + +def _agent_cell(e: Any) -> Text: + """The ``agent`` column for a session row: the root agent name, plus ``+N`` (N = the number of + OTHER agents that ran) in the accent colour when the session is multi-agent. The extra agents' + names are never listed inline — the badge keeps the roster one row wide (use ``--agents`` to + expand into the full list).""" + txt = Text(getattr(e, "agent_id", "") or "-", style=theme.TEXT) + extra = len(_session_agents(e)) - 1 + if extra > 0: + txt.append(" ") + txt.append(f"+{extra}", style=theme.ACCENT) + return txt + + +def _session_row_time(e: Any) -> str: + """The session's last-activity timestamp for the time column, with fallbacks (started_at, then + an eval-shaped completed_at/created_at) so a stray Evaluation-shaped row still renders.""" + return (getattr(e, "last_event_at", "") or getattr(e, "started_at", "") + or getattr(e, "completed_at", "") or getattr(e, "created_at", "")) + + +def render_sessions(items: Sequence[Any], *, order: Optional[str] = None, + full_ids: bool = False, empty_message: str = "no sessions") -> None: + """The default ``sessions`` view: columns ``time · env · agent · session · status`` in the + shared list panel — agent bright (plus ``+N`` in accent when multi-agent), the rest + contextual-dim; status coloured by state. ``time`` is the session's last activity + (``last_event_at``), ``status`` its latest evaluation outcome (blank if never evaluated). + Scores live on ``evals``, not here. Session ids truncate unless ``full_ids``. The ``--json`` / + ``--fields`` paths never reach here. When empty the caller passes a filter-aware + ``empty_message``.""" + tcells, days = _row_times([_parse_iso(_session_row_time(e)) for e in items]) + rows = [] + for e, t in zip(items, tcells): + rows.append([ + Text(t if t is not None else (_session_row_time(e) or "-"), style=theme.TEXT_DIM), + Text(getattr(e, "environment", "") or "-", style=theme.LABEL), + _agent_cell(e), + Text(_short_session(getattr(e, "session_id", "") or "-", full=full_ids), style=theme.TEXT_DIM), + _status_cell(getattr(e, "status", "") or "-"), + ]) + render_list_panel("sessions", header=["time", "env", "agent", "session", "status"], + rows=rows, days=days, order=order, empty_message=empty_message) + + +def render_sessions_expanded(items: Sequence[Any], *, order: Optional[str] = None, + full_ids: bool = False, empty_message: str = "no sessions") -> None: + """``sessions --agents``: the same list, but every multi-agent session is expanded into an + indented roster beneath its row — ``├ name N ev`` for every agent (uniform, ordered by event + count desc; ``└`` closes the list, no special "root" marker). Single-agent sessions render as a + normal row. The panel's count stays the number of sessions — the roster sub-rows aren't counted.""" + tcells, days = _row_times([_parse_iso(_session_row_time(e)) for e in items]) + rows = [] + for e, t in zip(items, tcells): + rows.append([ + Text(t if t is not None else (_session_row_time(e) or "-"), style=theme.TEXT_DIM), + Text(getattr(e, "environment", "") or "-", style=theme.LABEL), + _agent_cell(e), + Text(_short_session(getattr(e, "session_id", "") or "-", full=full_ids), style=theme.TEXT_DIM), + _status_cell(getattr(e, "status", "") or "-"), + ]) + agents = _session_agents(e) + if len(agents) > 1: + width = max(len(str(a.get("agent_id", ""))) for a in agents) + n = len(agents) + for i, a in enumerate(agents): + # Uniform glyph — no special "root" marker; `└` just closes the list. + glyph = "└" if i == n - 1 else "├" + cell = Text(" ") + cell.append(f"{glyph} ", style=theme.FAINT) + cell.append(str(a.get("agent_id", "")).ljust(width), style=theme.TEXT) + cell.append(" ") + cell.append(f"{a.get('event_count', 0)} ev", style=theme.LABEL) + rows.append([Text(""), Text(""), cell, Text(""), Text("")]) + render_list_panel("sessions", header=["time", "env", "agent", "session", "status"], + rows=rows, days=days, order=order, empty_message=empty_message, + title=_panel_title("sessions", len(items), order, days)) + + +def render_evals(items: Sequence[Any], *, order: Optional[str] = None, + full_ids: bool = False, scores_full: bool = False, + empty_message: str = "no evals") -> None: + """The default ``evals`` list view: same columns as ``sessions`` plus a final ``scores`` + column — agent bright, the rest contextual-dim; status coloured by state; scores coloured + by value (≥.85 green / .70–.85 amber / <.70 red). Session ids truncate unless ``full_ids``; + scores are width-fitted (then ``+N``) so an eval stays one row and the fixed columns never + squeeze, unless ``scores_full``. The ``--json`` / ``--fields`` paths never reach here.""" + times, envs, agents, sessions, statuses, days = _eval_row_columns(items, full_ids=full_ids) + + budget: Optional[int] = None + if items and not scores_full: + fixed = sum(max((len(x) for x in col), default=0) for col in (times, envs, agents, sessions, statuses)) + budget = max(SCORES_MIN_WIDTH, _stdout.width - fixed - _LIST_CHROME) + + rows = [] + for i, e in enumerate(items): + rows.append([ + Text(times[i], style=theme.TEXT_DIM), + Text(envs[i], style=theme.LABEL), + Text(agents[i], style=theme.TEXT), + Text(sessions[i], style=theme.TEXT_DIM), + _status_cell(statuses[i]), + _scores_cell(getattr(e, "scores", None), budget=budget, full=scores_full), + ]) + render_list_panel("evals", header=["time", "env", "agent", "session", "status", "scores"], + rows=rows, days=days, order=order, empty_message=empty_message, + last_col=("wrap" if scores_full else "ellipsis"), last_col_max=budget) + + +def _footer_line(command: str, shown: int, more: bool) -> Text: + line = Text(" ") + line.append(f"{shown} shown", style=theme.LABEL) + if more: + line.append(" · ", style=theme.FAINT) + line.append("more available", style=theme.LABEL) + line.append(" · ", style=theme.FAINT) + line.append(f"fp {command} --all", style=theme.ACCENT) + return line + + +def events_footer(shown: int, *, more: bool, command: str = "events") -> None: + """The dim summary line under the events box (stderr): ``<n> shown`` and, when the server + has more rows, ``· more available · fp <command> --all`` with the command glowed.""" + if _quiet: + return + _stderr.print(_footer_line(command, shown, more)) + _stderr.print() + + +# Filters that narrow a list (events/sessions), mapped to the `fp list <facet>` that +# enumerates their valid values (so a 0-result run with a typo'd value points the user at the +# right answer). Shared across commands: a filter with no enumerable facet (e.g. --session-id, +# --status, --since) simply gets named without a "see valid values" line. +_FILTER_FACETS = { + "--env": "envs", + "--agent-id": "agents", + "--event-type": "event_types", + "--error-type": "error_types", +} + + +def recheck_filters_hint(active_filters: Sequence[str]) -> None: + """Dim stderr nudge shown when a *filtered* list returns 0 rows: name the filters the user + set and, for those with a discoverable value set, point at ``fp list <facet>``. This + makes a typo'd value (the ``--env xyz`` case) read as "check your filters" rather than the + misleading "no data exists" — the server silently returns 0 for any value that matches + nothing, so the CLI can't tell a bad value from a genuinely empty slice. No-op when no + filters were active (a bare run with 0 rows is a real empty window, not a typo).""" + if _quiet or not active_filters: + return + line = Text(" ") + line.append("↳ ", style=theme.FAINT) + line.append("no matches — double-check the value", style=theme.LABEL) + line.append("s" if len(active_filters) > 1 else "", style=theme.LABEL) + line.append(" you passed for ", style=theme.LABEL) + for i, flag in enumerate(active_filters): + if i: + line.append(", ", style=theme.FAINT) + line.append(flag, style=theme.ACCENT) + _stderr.print(line) + facets = [_FILTER_FACETS[f] for f in active_filters if f in _FILTER_FACETS] + if facets: + l2 = Text(" ") + l2.append("see valid values: ", style=theme.FAINT) + for i, fa in enumerate(facets): + if i: + l2.append(" · ", style=theme.FAINT) + l2.append(f"fp list {fa}", style=theme.ACCENT) + _stderr.print(l2) + _stderr.print() + + +def sessions_footer(shown: int, *, more: bool, multi_agent: int = 0) -> None: + """The sessions summary line (stderr): ``<n> shown``; then ``· <m> multi-agent · fp + sessions --agents`` when any shown session ran more than one agent (nudging the roster + expand); then ``· more available · fp sessions --all`` when the server has more rows. + No score legend (sessions has no scores).""" + if _quiet: + return + line = Text(" ") + line.append(f"{shown} shown", style=theme.LABEL) + if multi_agent > 0: + line.append(" · ", style=theme.FAINT) + line.append(f"{multi_agent} multi-agent", style=theme.LABEL) + line.append(" · ", style=theme.FAINT) + line.append("fp sessions --agents", style=theme.ACCENT) + if more: + line.append(" · ", style=theme.FAINT) + line.append("more available", style=theme.LABEL) + line.append(" · ", style=theme.FAINT) + line.append("fp sessions --all", style=theme.ACCENT) + _stderr.print(line) + _stderr.print() + + +def _score_legend() -> Text: + """Compact score-band legend for the evals footer (the cut points aren't obvious).""" + t = Text() + t.append("score: ", style=theme.LABEL) + t.append("≥.80", style=theme.SCORE_HIGH) + t.append(" ") + t.append(".50–.80", style=theme.AMBER) + t.append(" ") + t.append("<.50", style=theme.ERROR) + return t + + +def evals_footer(shown: int, *, more: bool) -> None: + """The evals list summary line (stderr): the shared pagination line plus a compact + score-colour legend on the same line. The legend is dropped (not wrapped) if the terminal + is too narrow.""" + if _quiet: + return + line = _footer_line("evals", shown, more) + legend = _score_legend() + if line.cell_len + 4 + legend.cell_len <= _stderr.width: + line.append(" ") + line.append_text(legend) + _stderr.print(line) + _stderr.print() + + +# ── eval aggregate (evals --aggregate) — presentation only ─────────────────── + +# Stable display order for the status dots; any bucket not listed sorts after these +# (built dynamically so a new backend status never crashes the card). +_AGG_STATUS_ORDER = ["done", "passed", "error", "failed", "timeout", "cancelled"] + + +def _success_rate_color(pct: float) -> str: + """Success-rate colour: ≥95% green, 85–95% amber, <85% red.""" + return theme.SUCCESS if pct >= 95 else (theme.AMBER if pct >= 85 else theme.ERROR) + + +# ── score bar config ── +BAR_CELLS = 10 +BAR_LO, BAR_HI = 0.40, 1.00 # zoomed scale: the typical .7–.9 range shows visible variation +# Empty-track tint, nudged toward each band so the track reads as "the rest of this bar". +_BAR_TRACK_TINT = {theme.SCORE_HIGH: "#1d3833", theme.AMBER: "#3a352d", theme.ERROR: "#3a2d2d"} + + +def _bar_glyphs() -> tuple: + """(fill, track) glyphs. Braille (`⣿`/`⣀`) when colour is on — prettier, with built-in + inter-cell spacing; solid blocks (`█`/`░`) otherwise (mono-safe, clearer fill contrast and + a universally-rendered fallback for fonts that mis-advance wide unicode).""" + enc = str(getattr(_stdout, "encoding", "utf-8") or "utf-8").lower() + return ("⣿", "⣀") if (not _no_color and "utf" in enc) else ("█", "░") + + +def _avg_bar(avg: Optional[float]) -> Text: + """A 10-cell mini-bar for an average on the zoomed `.40–1.0` scale: filled cells in the + score's band colour, the rest a band-tinted track. Conveys the value by fill level alone, + so it still works under NO_COLOR. Lives in its own table column, so even if a font advances + braille differently the later columns stay aligned (Rich pads to the cell width).""" + fill_g, track_g = _bar_glyphs() + if avg is None: + return Text(track_g * BAR_CELLS, style=theme.BAR_EMPTY) + color = _score_color(avg) + frac = max(0.0, min(1.0, (avg - BAR_LO) / (BAR_HI - BAR_LO))) + filled = round(frac * BAR_CELLS) + bar = Text() + bar.append(fill_g * filled, style=color) + bar.append(track_g * (BAR_CELLS - filled), style=_BAR_TRACK_TINT.get(color, theme.BAR_EMPTY)) + return bar + + +def _score_bar_legend() -> Text: + """One dim line explaining the bar's zoomed scale + colour bands.""" + fill_g, _ = _bar_glyphs() + t = Text(" ") + t.append("scale .40–1.0 · ", style=theme.LABEL) + t.append(fill_g, style=theme.SCORE_HIGH); t.append(" ≥.80 ", style=theme.LABEL) + t.append(fill_g, style=theme.AMBER); t.append(" .50–.80 ", style=theme.LABEL) + t.append(fill_g, style=theme.ERROR); t.append(" <.50", style=theme.LABEL) + return t + + +def render_eval_aggregate(data: dict, *, show_bar: Optional[bool] = None) -> None: + """The ``evals --aggregate`` view (stdout): a totals card (hero count + colour-coded status + dots + a derived success-rate line) then a score-stats table — one row per metric with its + sample count, threshold-coloured avg + a mini-bar, and min/max/p50 — sorted worst-avg first, + every metric shown. Presentation only (``--json`` emits the raw payload). Higher == better is + assumed for every metric (the CLI has no per-metric direction flag), so an inverted metric + such as a raw `toxicity` would read 'backwards' — uniform rule, not special-cased.""" + total = int(data.get("total", 0) or 0) + counts = {k: int(v or 0) for k, v in (data.get("status_counts", {}) or {}).items()} + + # ── panel 1: totals card ── + line1 = Text() + line1.append(str(total), style=f"bold {theme.TEXT}") + line1.append(" evals", style=theme.LABEL) + for b in sorted(counts, key=lambda x: (_AGG_STATUS_ORDER.index(x) if x in _AGG_STATUS_ORDER else len(_AGG_STATUS_ORDER), x)): + n = counts[b] + zero = n == 0 + line1.append(" ") + line1.append("○" if zero else "●", style=theme.FAINT if zero else _STATUS_COLORS.get(b, theme.TEXT_DIM)) + line1.append(f" {n} {b}", style=theme.LABEL if zero else theme.TEXT) + + done = counts.get("done", 0) + counts.get("passed", 0) + line2 = Text() + if total > 0: + pct = done / total * 100 + line2.append(f"{pct:.1f}%", style=_success_rate_color(pct)) + line2.append(" success rate", style=theme.LABEL) + else: + line2.append("no evals in this window", style=theme.TEXT_DIM) + + card = Panel(Group(line1, line2), box=ROUNDED, border_style=theme.ACCENT, + title=Text("eval-aggregate", style=f"bold {theme.ACCENT}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print() + _stdout.print(Padding(card, (0, 0, 0, 2))) + + # ── panel 2: score stats table ── + stats = list(data.get("score_stats", []) or []) + if not stats: + # No metrics (e.g. an empty/0-total slice). Match the non-empty path, which ends + # with a trailing blank under its legend, so a following recheck-hint has separation. + if not _quiet: + _stderr.print() + return + # Worst average first (problem metrics surface at the top); no-avg metrics sort last. + stats.sort(key=lambda s: (s.get("avg") is None, s.get("avg") if s.get("avg") is not None else 0.0)) + + if show_bar is None: + show_bar = _stdout.width >= 80 # the bar is the most expendable column on a narrow terminal + + header = ["metric", "n", "avg"] + ([""] if show_bar else []) + ["min", "max", "p50"] + rows = [] + for s in stats: + avg = s.get("avg") + numeric = isinstance(avg, (int, float)) + color = _score_color(float(avg)) if numeric else theme.TEXT_DIM + avg_label = _fmt_avg(float(avg)) if numeric else "-" + if _no_color and numeric and float(avg) < SCORE_OK: + avg_label += "!" + row = [ + Text(str(s.get("key", "")), style=theme.TEXT), + Text(str(s.get("count", 0)), style=theme.TEXT_DIM), + Text(avg_label, style=color), + ] + if show_bar: + row.append(_avg_bar(float(avg) if numeric else None)) + for k in ("min", "max", "p50"): + v = s.get(k) + row.append(Text(_fmt_score_num(float(v)) if isinstance(v, (int, float)) else "-", style=theme.TEXT_DIM)) + rows.append(row) + + title = Text() + title.append("score stats", style=f"bold {theme.ACCENT}") + title.append(f" · {len(stats)} metrics · sorted by avg", style=theme.LABEL) + render_list_panel("score stats", header=header, rows=rows, days=set(), order=None, + empty_message="no scores", title=title) + # one dim legend line under the panel (stderr chrome) — explains the scale + bands + if not _quiet: + _stderr.print(_score_bar_legend()) + _stderr.print() + + +# ── errors (errors list + errors-aggregate card) — presentation only ───────── + + +def _truncate(s: str, n: int = 80) -> str: + return s[: n - 1] + "…" if len(s) > n else s + + +def _event_cell(event_type: str) -> Text: + """The errors ``event`` cell: ``● {event_type}``. Red when the type itself names an error + (`error`/`fail` substring) — the only coloured marker; everything else is neutral dim. No + per-event-type colour map (the CLI has no fixed enum). NO_COLOR: ``! {type}`` marks errors.""" + et = event_type or "-" + is_err = "error" in et.lower() or "fail" in et.lower() + t = Text() + if _no_color: + t.append("! " if is_err else " ") # '!' marks error rows; pad others to align + t.append(et) + else: + t.append("● ", style=theme.ERROR if is_err else theme.FAINT) + t.append(et, style=theme.ERROR if is_err else theme.TEXT_DIM) + return t + + +def _errors_title(count: int, order: Optional[str], days) -> Text: + """The errors-list title — red ``errors`` word + dim-red metadata.""" + t = Text() + t.append("errors", style=f"bold {theme.ERROR}") + t.append(f" · {count}", style=theme.TITLE_ERROR_DIM) + t.append(f" · {'oldest first' if order == 'asc' else 'newest first'}", style=theme.TITLE_ERROR_DIM) + if days: + span = f"{min(days)} → {max(days)}" if len(days) > 1 else str(next(iter(days))) + t.append(f" · {span}", style=theme.TITLE_ERROR_DIM) + return t + + +def render_errors(items: Sequence[Any], *, order: Optional[str] = None, full_ids: bool = False, + empty_message: str = "no errors") -> None: + """The ``errors`` list view (stdout): an error-themed (muted red-purple border) boxed table — + columns ``time · event · env · agent · session · summary``. agent/summary bright, the rest + contextual-dim; the event marker is red only when the type names an error. The ``summary`` + is the server-computed ``summary`` field from the light ``/events/summary`` feed (the CLI + never parses the raw payload) and truncates with `…` (never wraps). When empty the caller + passes a filter-aware ``empty_message``. ``--json``/``--fields`` skip this.""" + tcells, days = _row_times([_parse_iso(getattr(e, "ts", "")) for e in items]) + times = [t if t is not None else (getattr(e, "ts", "") or "-") for e, t in zip(items, tcells)] + etypes = [getattr(e, "event_type", "") or "-" for e in items] + envs = [getattr(e, "environment", "") or "-" for e in items] + agents = [getattr(e, "agent_id", "") or "-" for e in items] + sessions = [_short_session(getattr(e, "session_id", "") or "-", full=full_ids) for e in items] + + # Cap the summary column to the leftover width so it can run to the edge + truncate, but + # never squeezes the fixed columns before it (same approach as the evals scores column). + budget: Optional[int] = None + if items: + fixed = sum(( + max((len(x) for x in times), default=0), + max((len(et) + 2 for et in etypes), default=0), # "● " marker + type + max((len(x) for x in envs), default=0), + max((len(x) for x in agents), default=0), + max((len(x) for x in sessions), default=0), + )) + budget = max(SCORES_MIN_WIDTH, _stdout.width - fixed - _LIST_CHROME) + + rows = [] + for i, e in enumerate(items): + rows.append([ + Text(times[i], style=theme.TEXT_DIM), + _event_cell(etypes[i]), + Text(envs[i], style=theme.LABEL), + Text(agents[i], style=theme.TEXT), + Text(sessions[i], style=theme.TEXT_DIM), + Text((getattr(e, "summary", "") or "-"), style=theme.TEXT), + ]) + render_list_panel("errors", header=["time", "event", "env", "agent", "session", "summary"], + rows=rows, days=days, order=order, empty_message=empty_message, + last_col="ellipsis", last_col_max=budget, + title=_errors_title(len(items), order, days), + border=theme.BORDER_ERROR, rule=theme.RULE_ERROR) + + +def errors_footer(shown: int, *, more: bool) -> None: + """The errors-list summary line (stderr): ``<n> shown`` + ``· more available · fp + errors --all``. No legend (errors are the only coloured marker).""" + if _quiet: + return + _stderr.print(_footer_line("errors", shown, more)) + _stderr.print() + + +def _relative_age(ts_iso: Optional[str]) -> str: + """Humanize an ISO timestamp as ``8 min ago`` / ``2 hr ago`` / ``3 days ago`` (``""`` if + unparseable). The recency is the actionable bit on the errors card.""" + dt = _parse_iso(ts_iso or "") + if dt is None: + return "" + secs = max(0.0, (datetime.now(timezone.utc) - dt).total_seconds()) + if secs < 90: + return f"{int(secs)} sec ago" + if secs < 90 * 60: + return f"{int(round(secs / 60))} min ago" + if secs < 36 * 3600: + return f"{int(round(secs / 3600))} hr ago" + return f"{int(round(secs / 86400))} days ago" + + +def render_error_aggregate(data: dict) -> None: + """The ``errors --aggregate`` card (stdout): a large red hero count + an ``across N sessions + · N agents · last <relative>`` line, in the errors-themed red-purple panel. Zero errors → + a calm green ``✓ no errors found`` in a neutral ACCENT panel. Presentation only.""" + total = int(data.get("total", 0) or 0) + + if total == 0: + # Consistent red errors border regardless of count (the green ✓ carries the good news). + panel = Panel(Text("✓ no errors found", style=theme.SUCCESS), box=ROUNDED, + border_style=theme.BORDER_ERROR, + title=Text("errors-aggregate", style=f"bold {theme.ERROR}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print() + _stdout.print(Padding(panel, (0, 0, 0, 2))) + # Trailing blank so a following recheck-hint (when filters are active) is separated. + if not _quiet: + _stderr.print() + return + + sep = theme.TITLE_ERROR_DIM + line1 = Text() + line1.append(str(total), style=f"bold {theme.ERROR}") # the hero count + line1.append(" errored events", style=theme.LABEL) + + line2 = Text() + line2.append("across ", style=theme.FAINT) + line2.append(str(int(data.get("sessions", 0) or 0)), style=f"bold {theme.TEXT}") + line2.append(" sessions", style=theme.LABEL) + line2.append(" · ", style=sep) + line2.append(str(int(data.get("agents", 0) or 0)), style=f"bold {theme.TEXT}") + line2.append(" agents", style=theme.LABEL) + age = _relative_age(data.get("last_ts")) + if age: + line2.append(" · ", style=sep) + line2.append("last ", style=theme.LABEL) + line2.append(age, style=theme.ERROR) + + panel = Panel(Group(line1, line2), box=ROUNDED, border_style=theme.BORDER_ERROR, + title=Text("errors-aggregate", style=f"bold {theme.ERROR}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print() + _stdout.print(Padding(panel, (0, 0, 0, 2))) + + +# ── list <kind> (value discovery) — presentation only ─────────────────────── + +LIST_COL_HEIGHT = 8 # a column fills to this many rows, then overflows to the next +_LIST_GUTTER = 3 # spaces between columns +_LIST_CHROME = 8 # panel border + padding + left indent, reserved when capping columns + + +def render_value_list(kind: str, values: Sequence[str], *, description: str = "") -> None: + """The shared ``list <kind>`` view (stdout): an ACCENT panel titled ``{kind} · {n} {desc}`` + (the count ``{n}`` glows bold-white) with the (sorted) values in **column-major flow** — fill + a column of ``LIST_COL_HEIGHT``, then overflow to the next; the column count is capped to the + terminal width (preferring taller over wider-than-screen, min one column). Empty → ``none + found``.""" + vals = sorted(values) + n = len(vals) + + if n == 0: + body: Any = Text("none found", style=theme.TEXT_DIM) + else: + col_w = max(len(v) for v in vals) + _LIST_GUTTER + max_cols = max(1, (_stdout.width - _LIST_CHROME) // col_w) + height = LIST_COL_HEIGHT + ncols = -(-n // height) # ceil(n / height) + if ncols > max_cols: # too wide → fewer columns, taller + ncols = max_cols + height = -(-n // ncols) + columns = [vals[i * height:(i + 1) * height] for i in range(ncols)] + nrows = len(columns[0]) + table = Table(box=None, pad_edge=False, show_header=False, padding=(0, _LIST_GUTTER, 0, 0)) + for _ in range(ncols): + table.add_column(no_wrap=True, style=theme.TEXT) + for r in range(nrows): + table.add_row(*[(columns[c][r] if r < len(columns[c]) else "") for c in range(ncols)]) + body = table + + title = Text() + title.append(kind, style=f"bold {theme.ACCENT}") + title.append(" · ", style=theme.FAINT) + title.append(str(n), style="bold white") # the count glows white — the headline of the list + if description: + title.append(f" {description}", style=theme.LABEL) + panel = Panel(body, box=ROUNDED, border_style=theme.ACCENT, title=title, + title_align="left", padding=(0, 1), expand=False) + _stdout.print() + _stdout.print(Padding(panel, (0, 0, 0, 2))) + + +# ── keys (list box + destructive confirm/cancel + secret reveal) ───────────── + +# Key status → (marker, colour). A small fixed enum; filled ● = live/usable, +# hollow ○ = dead/unusable. Unknown statuses fall back to a neutral dim dot. +_KEY_STATUS = { + "active": ("●", theme.SUCCESS), + "pending": ("●", theme.AMBER), + "revoked": ("○", theme.ERROR), + "expired": ("○", theme.ERROR), + "disabled": ("○", theme.ERROR), +} + + +def _short_id(value: str) -> str: + """A long id → ``1f58…9826`` (first 4 + ``…`` + last 4); short ids unchanged.""" + return value[:4] + "…" + value[-4:] if len(value) > 9 else value + + +def _short_chat_id(value: str) -> str: + """A chat id → its short, copy-friendly handle: the segment before the first ``-`` (the first + 8 hex of a UUID, e.g. ``07854990-dade-…`` → ``07854990``), or the first 8 chars when there's no + ``-``. The CLI resolves this prefix back to the full id against the chat list, while the server + keeps the full id intact.""" + s = str(value or "") + if "-" in s: + return s.split("-", 1)[0] + return s[:8] if len(s) > 8 else s + + +def _fmt_key_created(iso: str) -> str: + """Compact key-created stamp: ``MM-DD HH:MM`` (year/seconds dropped); raw if unparsable.""" + dt = _parse_iso(iso) + return dt.strftime("%m-%d %H:%M") if dt is not None else (iso or "-") + + +def _key_status_cell(status: str) -> Text: + marker, color = _KEY_STATUS.get(status.lower(), ("●", theme.FAINT)) + label_style = color if status.lower() in _KEY_STATUS else theme.TEXT_DIM + t = Text() + t.append(marker + " ", style=color if status.lower() in _KEY_STATUS else theme.FAINT) + t.append(status, style=label_style) + return t + + +def render_keys(keys: Sequence[Any], *, show_id: bool = False) -> None: + """The ``keys list`` view (stdout): an ACCENT panel titled ``api keys · {n} · active first`` + with columns ``created · name · permissions · status`` (status colour-coded by the key-status + enum). **Active keys sort to the top** (then revoked), each group newest-first. ``show_id`` + prepends a short id column. The raw id / full ISO live only in ``--json``.""" + items = sorted(keys, key=lambda k: getattr(k, "created_at", "") or "", reverse=True) # newest first + items.sort(key=lambda k: 1 if getattr(k, "revoked_at", None) else 0) # then active(0) before revoked(1) + + header = (["id"] if show_id else []) + ["created", "name", "permissions", "status"] + rows = [] + for k in items: + status = "revoked" if getattr(k, "revoked_at", None) else "active" + row = [Text(_short_id(getattr(k, "id", "") or "-"), style=theme.TEXT_DIM)] if show_id else [] + row += [ + Text(_fmt_key_created(getattr(k, "created_at", "")), style=theme.TEXT_DIM), + Text(getattr(k, "name", "") or "-", style=theme.TEXT), + Text(str(len(getattr(k, "permissions", []) or [])), style=theme.TEXT_DIM), + _key_status_cell(status), + ] + rows.append(row) + + title = Text() + title.append("api keys", style=f"bold {theme.ACCENT}") + title.append(f" · {len(items)} · active first", style=theme.LABEL) + render_list_panel("api keys", header=header, rows=rows, days=set(), order=None, + empty_message="no keys", title=title) + + +def keys_footer(keys: Sequence[Any]) -> None: + """Status summary under the keys box (stderr): ``{total} keys · {n} active · {m} revoked``, + each count in its status colour. Built from the actual statuses present.""" + if _quiet: + return + total = len(keys) + active = sum(1 for k in keys if not getattr(k, "revoked_at", None)) + revoked = total - active + line = Text(" ") + line.append(f"{total} keys", style=theme.LABEL) + if active: + line.append(" · ", style=theme.FAINT) + line.append(f"{active} active", style=theme.SUCCESS) + if revoked: + line.append(" · ", style=theme.FAINT) + line.append(f"{revoked} revoked", style=theme.ERROR) + _stderr.print(line) + _stderr.print() + + +def _notice_box(body: Any, *, color: str, title: str) -> None: + """A small rounded notice box (stderr): ``color``-bordered, ``title`` in the border, ``body`` + a pre-styled Text/Group. The colour carries the meaning (amber confirm / red error / green + success / faint neutral). The shared shape for the key-action flow states, so the whole + `keys` surface reads as one boxed family.""" + panel = Panel(body, box=ROUNDED, border_style=color, + title=Text(title, style=f"bold {color}"), title_align="left", + padding=(0, 1), expand=False) + _stderr.print() + _stderr.print(Padding(panel, (0, 0, 0, 2))) + + +def confirm_prompt(action: str, target: str, consequence: str, *, + glyph: str = "⚠", color: Optional[str] = None, title: str = "confirm") -> bool: + """Render a boxed confirm prompt (stderr) and ask ``[y/N]`` (default NO): a ``color``-bordered + ``title`` box holding ``{glyph} {action} {target}?`` + the dim consequence, then a + ``confirm? [y/N]`` line below it. Returns the answer. Defaults to the amber ⚠ destructive + shape (shared by the key actions + ``users disable``); ``users enable`` passes the calm ACCENT + ``↑`` re-activation glyph instead (enabling restores access — not a warning).""" + color = color or theme.AMBER + line1 = Text() + line1.append(f"{glyph} ", style=f"bold {color}") + line1.append(action, style=theme.TEXT) + line1.append(" ") + line1.append(target, style=f"bold {theme.ACCENT}") + line1.append("?", style=theme.TEXT) + _notice_box(Group(line1, Text(consequence, style=theme.LABEL)), color=color, title=title) + return typer.confirm(_ansi(" confirm?", dim=True), default=False, err=True, prompt_suffix=" ") + + +def print_cancelled(message: str = "nothing changed") -> None: + """The calm cancel box (stderr): a faint ``cancelled`` box (``○ {message}``). NOT an error — + declining a destructive action is a good outcome. Shared by the destructive key actions + (``message`` lets update say ``permissions unchanged``).""" + body = Text() + body.append("○ ", style=theme.FAINT) + body.append(message, style=theme.LABEL) + _notice_box(body, color=theme.FAINT, title="cancelled") + + +def key_error(message: str) -> None: + """A red error box (stderr) for a plain key-action error message (bad permission token, …).""" + body = Text() + body.append("✗ ", style=f"bold {theme.ERROR}") + body.append(message, style=theme.TEXT) + _notice_box(body, color=theme.ERROR, title="error") + + +def key_exists(name: str) -> None: + """A red error box (stderr): ``✗ a key named <name> already exists``.""" + body = Text() + body.append("✗ ", style=f"bold {theme.ERROR}") + body.append("a key named ", style=theme.TEXT) + body.append(name, style=f"bold {theme.ACCENT}") + body.append(" already exists", style=theme.TEXT) + _notice_box(body, color=theme.ERROR, title="error") + + +def key_action_line(action: str, name: str, count: int) -> None: + """The shared create/update success line (stderr): ``✓ created key <name> · N permissions`` + or ``✓ updated key <name> · now N permissions``.""" + line = Text(" ") + line.append("✓ ", style=theme.SUCCESS) + if action == "created": + line.append("created key ", style=theme.TEXT) + line.append(name, style=theme.ACCENT) + line.append(" · ", style=theme.FAINT) + line.append(f"{count} permissions", style=theme.LABEL) + else: # updated + line.append("updated key ", style=theme.TEXT) + line.append(name, style=theme.ACCENT) + line.append(" · ", style=theme.FAINT) + line.append(f"now {count} permissions", style=theme.LABEL) + _stderr.print() + _stderr.print(line) + + +def permissions_box(permissions: Sequence[str]) -> None: + """Print the shared grouped permissions panel to stderr — the create/update ending (same + component as ``whoami`` / ``orgs perms``, so a granted set reads identically CLI-wide).""" + _stderr.print(render_permissions_panel(permissions)) + _stderr.print() + + +# ── keys: show / created / updated cards (mirror the users cards) ──────────── + + +def _key_card_lines(key: Any) -> tuple: + """The two identity-card lines shared by ``keys show`` (ACCENT) and ``keys created`` (green): + line 1 the key name (bold); line 2 ``created {date} · {n} permissions · {status}`` (status + ● active / ○ revoked from ``revoked_at``). Mirrors ``_user_card_lines``.""" + revoked = bool(getattr(key, "revoked_at", None)) + line1 = Text(key.name or "-", style=f"bold {theme.TEXT}") + line2 = Text() + line2.append("created ", style=theme.LABEL) + line2.append(_fmt_key_created(getattr(key, "created_at", "")), style=theme.TEXT) + line2.append(" · ", style=theme.FAINT) + line2.append(f"{len(key.permissions)} permissions", style=theme.LABEL) + line2.append(" · ", style=theme.FAINT) + line2.append_text(_key_status_cell("revoked" if revoked else "active")) + return line1, line2 + + +def render_key_show(key: Any) -> None: + """The ``keys show <name>`` view (stdout): an identity card (``key``) then the shared grouped + permissions panel with ALL the key's grants — the key analogue of ``users show``.""" + line1, line2 = _key_card_lines(key) + card = Panel(Group(line1, line2), box=ROUNDED, border_style=theme.ACCENT, + title=Text("key", style=f"bold {theme.ACCENT}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print() + _stdout.print(Padding(card, (0, 0, 0, 2))) + _stdout.print(render_permissions_panel(key.permissions)) + _stdout.print() + + +def render_key_created(key: Any) -> None: + """The ``keys create`` identity card (stderr chrome): a GREEN-bordered ``key created`` card — + the green border is the success signal. The caller prints the secret box + permissions panel + after it (so the secret stays prominent). Mirrors ``render_user_created``.""" + line1, line2 = _key_card_lines(key) + card = Panel(Group(line1, line2), box=ROUNDED, border_style=theme.SUCCESS, + title=Text("key created", style=f"bold {theme.SUCCESS}"), + title_align="left", padding=(0, 1), expand=False) + _stderr.print() + _stderr.print(Padding(card, (0, 0, 0, 2))) + + +def render_key_updated(key: Any, *, added: Sequence[str], removed: Sequence[str], + union: Sequence[str]) -> None: + """The ``keys update`` result (stderr chrome): a GREEN summary card (``permissions updated · + {name}`` / ``now {n} · +{a} added · −{r} removed``) then the shared permissions panel in DIFF + mode + a dim legend. The key analogue of ``render_user_updated`` (keys have no role/set).""" + a, r = len(added), len(removed) + summary = Text() + summary.append(f"now {len(key.permissions)}", style=theme.LABEL) + summary.append(" · ", style=theme.FAINT) + summary.append(f"+{a} added", style=theme.SUCCESS) + summary.append(" · ", style=theme.FAINT) + summary.append(f"−{r} removed", style=theme.ERROR) + title = Text() + title.append("permissions updated", style=f"bold {theme.SUCCESS}") + title.append(" · ", style=theme.FAINT) + title.append(key.name or "-", style=f"bold {theme.SUCCESS}") + card = Panel(summary, box=ROUNDED, border_style=theme.SUCCESS, title=title, + title_align="left", padding=(0, 1), expand=False) + _stderr.print() + _stderr.print(Padding(card, (0, 0, 0, 2))) + _stderr.print(render_permissions_panel(union, diff={"added": list(added), "removed": list(removed)})) + _stderr.print(perm_diff_legend()) + _stderr.print() + + +def key_no_change() -> None: + """Calm no-op box (stderr) for ``keys update``: ``○ no change — permissions already match``.""" + body = Text() + body.append("○ ", style=theme.FAINT) + body.append("no change — permissions already match", style=theme.LABEL) + _notice_box(body, color=theme.FAINT, title="no change") + + +def confirm_key_update(name: str, added: int, removed: int) -> bool: + """The amber ``keys update`` confirm (stderr) showing the diff SCALE: ``⚠ change permissions on + key {name}?`` + ``+{a} / −{r} — the key keeps working, only its permissions change`` + ``confirm? + [y/N]`` (default NO). Mirrors ``confirm_user_update``.""" + line1 = Text() + line1.append("⚠ ", style=f"bold {theme.AMBER}") + line1.append("change permissions on key ", style=theme.TEXT) + line1.append(name, style=f"bold {theme.ACCENT}") + line2 = Text(" ") + line2.append(f"+{added}", style=theme.SUCCESS) + line2.append(" / ", style=theme.FAINT) + line2.append(f"−{removed}", style=theme.ERROR) + line2.append(" — the key keeps working, only its permissions change", style=theme.LABEL) + return confirm_line(line1, line2) + + +def render_created_secret_box(secret: str) -> None: + """The ``secret · shown once`` reveal box (stderr) for ``keys create``: green panel with the + secret on its own highlighted line + a ⚠ copy-it-now warning. The raw secret is printed to + stdout SEPARATELY by the caller (so a pipe captures just the secret).""" + sec = Text(" ") + sec.append(f" {secret} ", style=f"bold {theme.SUCCESS} on #13211c") + warn = Text() + warn.append("⚠ ", style=theme.AMBER) + warn.append("copy it now — it can't be retrieved again", style=theme.LABEL) + _notice_box(Group(sec, warn), color=theme.SUCCESS, title="secret · shown once") + + +def key_not_found(name: str) -> None: + """A red error box (stderr): ``✗ no key named <name>`` + a dim hint.""" + body = Text() + body.append("✗ ", style=f"bold {theme.ERROR}") + body.append("no key named ", style=theme.TEXT) + body.append(name, style=f"bold {theme.ACCENT}") + hint = Text() + hint.append("run ", style=theme.FAINT) + hint.append("fp keys list", style=theme.ACCENT) + hint.append(" to see your keys", style=theme.FAINT) + _notice_box(Group(body, hint), color=theme.ERROR, title="error") + + +def key_already_disabled(name: str) -> None: + """A calm no-op box (stderr): ``○ key <name> is already disabled``.""" + body = Text() + body.append("○ ", style=theme.FAINT) + body.append("key ", style=theme.LABEL) + body.append(name, style=theme.ACCENT) + body.append(" is already disabled", style=theme.LABEL) + _notice_box(body, color=theme.FAINT, title="no change") + + +def key_disabled(name: str) -> None: + """A green success box (stderr): ``✓ disabled key <name> · it can no longer be used``.""" + body = Text() + body.append("✓ ", style=theme.SUCCESS) + body.append("disabled key ", style=theme.TEXT) + body.append(name, style=theme.ACCENT) + body.append(" · ", style=theme.FAINT) + body.append("it can no longer be used", style=theme.LABEL) + _notice_box(body, color=theme.SUCCESS, title="disabled") + + +def render_secret_box(name: str, secret: str) -> None: + """The ``secret rotated`` reveal box (stderr) for an interactive regenerate: green panel, + the secret on its own highlighted line, a ⚠ shown-once warning. The raw secret is printed + to stdout SEPARATELY by the caller (so a pipe captures just the secret).""" + head = Text() + head.append("✓ ", style=theme.SUCCESS) + head.append("new secret for key ", style=theme.TEXT) + head.append(name, style=theme.ACCENT) + secret_line = Text(" ") + secret_line.append(f" {secret} ", style=f"bold {theme.SUCCESS} on #13211c") # stands out, easy to select + warn = Text() + warn.append("⚠ shown once", style=theme.AMBER) + warn.append(" — copy it now, it can't be retrieved again", style=theme.LABEL) + _notice_box(Group(head, Text(), secret_line, Text(), warn), color=theme.SUCCESS, title="secret rotated") + + +# ── users (member list + identity cards + create/update/disable/enable flows) ─ + +# The protected-member marker. 🔒 renders inconsistently across fonts/terminals, so it lives +# in its own fixed-width column (Rich pads it → a misrender can't shift later columns) and +# falls back to a text ``P`` under NO_COLOR / a non-unicode terminal. One swappable constant. +LOCK_GLYPH = "🔒" + + +def _unicode_ok() -> bool: + enc = str(getattr(_stdout, "encoding", "utf-8") or "utf-8").lower() + return not _no_color and "utf" in enc + + +def _lock_text(*, inline: bool = False) -> Text: + """The protected marker as a Text — 🔒 (amber) or the ``P`` fallback. ``inline`` adds the + trailing `` protected`` word (the identity-card / footer form).""" + glyph = LOCK_GLYPH if _unicode_ok() else "P" + t = Text() + t.append(glyph, style=theme.AMBER) + if inline: + t.append(" protected", style=theme.AMBER) + return t + + +def _lock_cell(protected: bool) -> Text: + """The leading list-column marker: 🔒/``P`` (amber) for a protected member, else blank.""" + return _lock_text() if protected else Text("") + + +def _user_status_cell(disabled: bool, *, muted: bool = False) -> Text: + """Member status derived from ``disabled_at``: ``● active`` (green) / ``○ disabled``. In a + dimmed list row the disabled status is muted (``muted``); on the identity card it's red.""" + t = Text() + if disabled: + c = theme.TEXT_DIM if muted else theme.ERROR + t.append("○ ", style=c) + t.append("disabled", style=c) + else: + t.append("● ", style=theme.SUCCESS) + t.append("active", style=theme.SUCCESS) + return t + + +def _fmt_user_joined(iso: str, multi_year: bool) -> str: + """Compact join date from ``created_at``: ``MM-DD`` (``YYYY-MM-DD`` when the list spans + more than one year); ``-`` if unparsable.""" + dt = _parse_iso(iso) + if dt is None: + return "-" + return dt.strftime("%Y-%m-%d" if multi_year else "%m-%d") + + +def render_users(users: Sequence[Any], *, show_id: bool = False) -> None: + """The ``users list`` view (stdout): an ACCENT panel titled ``users · {n}`` with columns + ``[lock] email · access · permissions · joined · status``. A leading narrow column carries the + protected 🔒 (amber, blank otherwise). Active members sort to the top (then disabled, which are + fully dimmed so active members dominate); status is derived from ``disabled_at``. ``joined`` + (from ``created_at``) only appears if at least one member has it. ``show_id`` adds a short id + column. The raw id / full timestamps live only in ``--json``.""" + items = sorted(users, key=lambda u: 1 if u.disabled_at else 0) # stable: active first + parsed = [_parse_iso(u.created_at) for u in items] + has_joined = any(p is not None for p in parsed) + multi_year = len({p.year for p in parsed if p is not None}) > 1 + + header = ([""] + (["id"] if show_id else []) + ["email", "access", "perms"] + + (["joined"] if has_joined else []) + ["status"]) + rows = [] + for u in items: + disabled = bool(u.disabled_at) + email_style = theme.TEXT_DIM if disabled else theme.TEXT + dim = theme.FAINT if disabled else theme.TEXT_DIM + row: List[Text] = [_lock_cell(u.is_protected)] + if show_id: + row.append(Text(_short_id(u.id or "-"), style=dim)) + row.append(Text(u.email or "-", style=email_style)) + row.append(Text(u.permission_set or "—", style=dim)) + row.append(Text(str(len(u.permissions)), style=dim)) + if has_joined: + row.append(Text(_fmt_user_joined(u.created_at, multi_year), style=dim)) + row.append(_user_status_cell(disabled, muted=disabled)) + rows.append(row) + + title = Text() + title.append("users", style=f"bold {theme.ACCENT}") + title.append(f" · {len(items)}", style=theme.LABEL) + render_list_panel("users", header=header, rows=rows, days=set(), order=None, + empty_message="no users", title=title) + + +def users_footer(users: Sequence[Any]) -> None: + """Membership-health summary under the users box (stderr): ``{total} users · {n} active · + {m} disabled · 🔒 {p} protected``, each count in its colour. The protected segment is omitted + when zero; ``P`` replaces 🔒 under NO_COLOR / non-unicode.""" + if _quiet: + return + total = len(users) + active = sum(1 for u in users if not u.disabled_at) + disabled = total - active + protected = sum(1 for u in users if u.is_protected) + line = Text(" ") + line.append(f"{total} users", style=theme.LABEL) + if active: + line.append(" · ", style=theme.FAINT) + line.append(f"{active} active", style=theme.SUCCESS) + if disabled: + line.append(" · ", style=theme.FAINT) + line.append(f"{disabled} disabled", style=theme.ERROR) + if protected: + line.append(" · ", style=theme.FAINT) + line.append(f"{LOCK_GLYPH if _unicode_ok() else 'P'} {protected} protected", style=theme.AMBER) + _stderr.print(line) + _stderr.print() + + +def _user_card_lines(user: Any) -> tuple: + """The two identity-card lines shared by ``users show`` (ACCENT) and ``users created`` + (green): line 1 the email (bold) + ``🔒 protected`` when protected; line 2 + ``access {set} · {n} permissions · {status}``.""" + line1 = Text() + line1.append(user.email or "-", style=f"bold {theme.TEXT}") + if user.is_protected: + line1.append(" ") + line1.append_text(_lock_text(inline=True)) + line2 = Text() + line2.append("access ", style=theme.LABEL) + line2.append(user.permission_set or "—", style=theme.TEXT) + line2.append(" · ", style=theme.FAINT) + line2.append(f"{len(user.permissions)} permissions", style=theme.LABEL) + line2.append(" · ", style=theme.FAINT) + line2.append_text(_user_status_cell(bool(user.disabled_at))) + return line1, line2 + + +def render_user_show(user: Any) -> None: + """The ``users show <email>`` view (stdout): an identity card (``user``) then the shared + grouped permissions panel with ALL the member's effective grants (no truncation — this is a + single-member detail view).""" + line1, line2 = _user_card_lines(user) + card = Panel(Group(line1, line2), box=ROUNDED, border_style=theme.ACCENT, + title=Text("user", style=f"bold {theme.ACCENT}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print() + _stdout.print(Padding(card, (0, 0, 0, 2))) + _stdout.print(render_permissions_panel(user.permissions)) + _stdout.print() + + +def render_user_created(user: Any) -> None: + """The ``users create`` success view (stderr chrome): a GREEN-bordered ``user created`` + identity card (the green border is the success signal — no tick) then the shared permissions + panel titled ``permissions · {n} · {set}``, showing all the new member's grants.""" + line1 = Text() + line1.append(user.email or "-", style=f"bold {theme.TEXT}") + line2 = Text() + line2.append("access ", style=theme.LABEL) + line2.append(user.permission_set or "—", style=theme.TEXT) + line2.append(" · ", style=theme.FAINT) + line2.append(f"{len(user.permissions)} permissions", style=theme.LABEL) + line2.append(" · ", style=theme.FAINT) + line2.append("● active", style=theme.SUCCESS) + card = Panel(Group(line1, line2), box=ROUNDED, border_style=theme.SUCCESS, + title=Text("user created", style=f"bold {theme.SUCCESS}"), + title_align="left", padding=(0, 1), expand=False) + _stderr.print() + _stderr.print(Padding(card, (0, 0, 0, 2))) + _stderr.print(render_permissions_panel(user.permissions, suffix=user.permission_set or None)) + _stderr.print() + + +def confirm_user_update(email: str, added: int, removed: int) -> bool: + """The amber update confirm (stderr) showing the diff SCALE before committing: ``⚠ change + permissions for {email}?`` + ``this replaces their current grants +{a} / −{r} (the user keeps + access)`` with the counts coloured, then ``confirm? [y/N]`` (default NO). Calm framing — a + permission change is reversible and doesn't lock the user out.""" + line1 = Text() + line1.append("⚠ ", style=f"bold {theme.AMBER}") + line1.append("change permissions for ", style=theme.TEXT) + line1.append(email, style=f"bold {theme.ACCENT}") + line1.append("?", style=theme.TEXT) + line2 = Text() + line2.append("this replaces their current grants ", style=theme.LABEL) + line2.append(f"+{added}", style=theme.SUCCESS) + line2.append(" / ", style=theme.FAINT) + line2.append(f"−{removed}", style=theme.ERROR) + line2.append(" (the user keeps access)", style=theme.LABEL) + _notice_box(Group(line1, line2), color=theme.AMBER, title="confirm") + return typer.confirm(_ansi(" confirm?", dim=True), default=False, err=True, prompt_suffix=" ") + + +def render_user_updated(user: Any, *, added: Sequence[str], removed: Sequence[str], + union: Sequence[str]) -> None: + """The ``users update`` result (stderr chrome): a GREEN summary card (``permissions updated · + {email}`` / ``{role} · now {n} · +{a} added · −{r} removed``) then the shared permissions panel + in DIFF mode — added grants as green chips, removed as red struck ghosts, unchanged dim — plus + a dim legend line.""" + a, r = len(added), len(removed) + summary = Text() + summary.append(user.permission_set or "—", style=theme.LABEL) + summary.append(" · ", style=theme.FAINT) + summary.append(f"now {len(user.permissions)}", style=theme.LABEL) + summary.append(" · ", style=theme.FAINT) + summary.append(f"+{a} added", style=theme.SUCCESS) + summary.append(" · ", style=theme.FAINT) + summary.append(f"−{r} removed", style=theme.ERROR) + title = Text() + title.append("permissions updated", style=f"bold {theme.SUCCESS}") + title.append(" · ", style=theme.FAINT) + title.append(user.email or "-", style=f"bold {theme.SUCCESS}") + card = Panel(summary, box=ROUNDED, border_style=theme.SUCCESS, title=title, + title_align="left", padding=(0, 1), expand=False) + _stderr.print() + _stderr.print(Padding(card, (0, 0, 0, 2))) + _stderr.print(render_permissions_panel(union, diff={"added": list(added), "removed": list(removed)})) + _stderr.print(perm_diff_legend()) + _stderr.print() + + +def _user_notice(mark: str, color: str, title: str, body_parts: Sequence[tuple], + *, hint: Optional[tuple] = None) -> None: + """Build + print a small user-flow notice box (stderr). ``body_parts`` is a list of + ``(text, style)`` segments after the ``mark``; ``hint`` an optional second dim line built + the same way (with command refs in ACCENT).""" + body = Text() + if mark: + body.append(f"{mark} ", style=f"bold {color}" if color != theme.FAINT else theme.FAINT) + for text, style in body_parts: + body.append(text, style=style) + group: Any = body + if hint is not None: + hint_line = Text() + for text, style in hint: + hint_line.append(text, style=style) + group = Group(body, hint_line) + _notice_box(group, color=color, title=title) + + +def user_not_found(email: str) -> None: + """Red error box (stderr): ``✗ no user with email "<email>"`` + a dim hint.""" + _user_notice("✗", theme.ERROR, "error", + [('no user with email "', theme.TEXT), (email, f"bold {theme.ACCENT}"), ('"', theme.TEXT)], + hint=[("run ", theme.FAINT), ("fp users list", theme.ACCENT), + (" to see members", theme.FAINT)]) + + +def user_exists(email: str) -> None: + """Red error box (stderr): ``✗ a user with email "<email>" already exists``.""" + _user_notice("✗", theme.ERROR, "error", + [('a user with email "', theme.TEXT), (email, f"bold {theme.ACCENT}"), + ('" already exists', theme.TEXT)]) + + +def user_error(message: str) -> None: + """Red error box (stderr) for a plain user-action error message (bad permission token, …).""" + _user_notice("✗", theme.ERROR, "error", [(message, theme.TEXT)]) + + +def user_no_change() -> None: + """Calm no-op box (stderr): ``○ no change — permissions already match`` (exit 0, no change).""" + _user_notice("○", theme.FAINT, "no change", + [("no change — permissions already match", theme.LABEL)]) + + +def user_disabled(email: str) -> None: + """Green success box (stderr): ``✓ disabled <email> · they can no longer sign in`` + a dim + ``re-enable with fp users enable <email>`` pointer (disabling is reversible).""" + _user_notice("✓", theme.SUCCESS, "disabled", + [("disabled ", theme.TEXT), (email, theme.ACCENT), + (" · ", theme.FAINT), ("they can no longer sign in", theme.LABEL)], + hint=[("re-enable with ", theme.FAINT), + (f"fp users enable {email}", theme.ACCENT)]) + + +def user_already_disabled(email: str) -> None: + """Calm no-op box (stderr): ``○ user "<email>" is already disabled``.""" + _user_notice("○", theme.FAINT, "no change", + [('user "', theme.LABEL), (email, theme.ACCENT), ('" is already disabled', theme.LABEL)]) + + +def user_protected_disable(email: str) -> None: + """Red error box (stderr): ``✗ "<email>" is protected and can't be disabled``.""" + _user_notice("✗", theme.ERROR, "error", + [('"', theme.TEXT), (email, f"bold {theme.ACCENT}"), + ('" is protected and can\'t be disabled', theme.TEXT)]) + + +def user_self_disable() -> None: + """Red error box (stderr): ``✗ you can't disable your own account``.""" + _user_notice("✗", theme.ERROR, "error", [("you can't disable your own account", theme.TEXT)]) + + +def user_enabled(email: str) -> None: + """Green success box (stderr): ``✓ enabled <email> · they can sign in again``.""" + _user_notice("✓", theme.SUCCESS, "enabled", + [("enabled ", theme.TEXT), (email, theme.ACCENT), + (" · ", theme.FAINT), ("they can sign in again", theme.LABEL)]) + + +def user_already_active(email: str) -> None: + """Calm no-op box (stderr): ``○ user "<email>" is already active``.""" + _user_notice("○", theme.FAINT, "no change", + [('user "', theme.LABEL), (email, theme.ACCENT), ('" is already active', theme.LABEL)]) + + +# ── saved queries (list box + show: metadata card + highlighted SQL) ───────── + +# The saved-query SQL runs against the read-only analytics pool (ClickHouse). The lexer is +# generic `sql` (Pygments handles ClickHouse funcs fine); the label is shown in the sql box title. +QUERY_SQL_DIALECT = "clickhouse" + +_sql_theme_cache: Any = None + + +def _sql_syntax_theme(): + """A custom Pygments syntax theme mapping SQL tokens to the brand palette (keywords ACCENT, + functions/builtins green, strings amber, numbers/params pink, identifiers TEXT, comments + faint). Built + cached lazily so the (heavyish) pygments import only happens on ``query show``, + not on every CLI invocation.""" + global _sql_theme_cache + if _sql_theme_cache is None: + from pygments.style import Style + from pygments.token import (Comment, Keyword, Name, Number, Operator, + Punctuation, String, Token) + from rich.syntax import PygmentsSyntaxTheme + + class _FpSqlStyle(Style): + background_color = "#0e0c12" # overridden by Syntax(background_color="default") + styles = { + Token: theme.TEXT, + Comment: f"italic {theme.FAINT}", + Keyword: f"bold {theme.ACCENT}", + Keyword.Type: theme.SCORE_HIGH, + Operator: theme.TEXT_DIM, + Operator.Word: f"bold {theme.ACCENT}", # AND / OR / NOT + Name: theme.TEXT, + Name.Builtin: theme.SUCCESS, + Name.Function: theme.SUCCESS, + Name.Variable: theme.PERM_WRITE, # :name / @var params + String: theme.AMBER, + String.Symbol: theme.AMBER, + Number: theme.PERM_WRITE, + Punctuation: theme.TEXT_DIM, + } + + _sql_theme_cache = PygmentsSyntaxTheme(_FpSqlStyle) + return _sql_theme_cache + + +def _fmt_query_date_full(iso: str) -> str: + """The query detail-card date: full ``YYYY-MM-DD`` (``-`` if unparsable).""" + dt = _parse_iso(iso) + return dt.strftime("%Y-%m-%d") if dt is not None else "-" + + +def _created_by_cell(created_by: Optional[str]) -> Text: + """``created_by`` cell: built-in ``system`` queries dim, a real username brighter (TEXT) so + team-made vs built-in is distinguishable at a glance.""" + cb = created_by or "system" + return Text(cb, style=theme.TEXT_DIM if cb == "system" else theme.TEXT) + + +def render_queries(queries: Sequence[Any], *, show_id: bool = False) -> None: + """The ``query list`` view (stdout): an ACCENT panel titled ``saved queries · {n}`` with columns + ``name · description · created by · created``. The long ``description`` is truncated to ONE line + (`…`) on a width budget so rows never grow; ``created`` is the compact ``created_at`` (`MM-DD`, + `YYYY-MM-DD` if the list spans years) — ``updated_at`` is not shown. ``name`` is the handle + (`query run`/`query show`); the raw id is hidden unless ``show_id``. Full description + ISO live + only in ``--json``. Newest-created first, then by name.""" + items = sorted(queries, key=lambda q: q.name or "") + items.sort(key=lambda q: q.created_at or "", reverse=True) # newest first, name tiebreak + parsed = [_parse_iso(q.created_at) for q in items] + multi_year = len({p.year for p in parsed if p is not None}) > 1 + + names = [q.name or "-" for q in items] + ids = [_short_id(q.id or "-") for q in items] if show_id else [] + created_by = [(q.created_by or "system") for q in items] + created = [_fmt_user_joined(q.created_at, multi_year) for q in items] + + header = (["id"] if show_id else []) + ["name", "description", "created by", "created"] + # Budget the description to the leftover width so every row stays one line. The other + # columns size to max(header, value) — so the wider HEADER ("created by") is counted, else + # the description over-runs and squeezes `name`. Reserve per-column padding + panel chrome. + budget: Optional[int] = None + if items: + def _col_w(label: str, vals) -> int: + return max(len(label), max((len(x) for x in vals), default=0)) + fixed = (_col_w("name", names) + _col_w("created by", created_by) + _col_w("created", created) + + (_col_w("id", ids) if show_id else 0)) + budget = max(SCORES_MIN_WIDTH, _stdout.width - fixed - (2 * len(header) + 8)) + + rows = [] + for i, q in enumerate(items): + desc = " ".join((q.description or "").split()) # collapse newlines/runs → one line + if budget is not None: + desc = _truncate(desc, budget) + row = ([Text(ids[i], style=theme.TEXT_DIM)] if show_id else []) + [ + Text(names[i], style=theme.TEXT), + Text(desc or "—", style=theme.TEXT_DIM), + _created_by_cell(created_by[i]), + Text(created[i], style=theme.TEXT_DIM), + ] + rows.append(row) + + title = Text() + title.append("saved queries", style=f"bold {theme.ACCENT}") + title.append(" · ", style=theme.FAINT) + title.append(str(len(items)), style="bold white") # the count glows — the headline of the list + render_list_panel("saved queries", header=header, rows=rows, days=set(), order=None, + empty_message="no saved queries", title=title) + + +def _query_sql_panel(sql_text: str, *, line_numbers: bool = True): + """The shared SQL box: a Rich ``Syntax`` (line-numbered, no reflow, brand palette) inside an + ACCENT ``sql · {dialect}`` panel. Returns the padded renderable so show/create/update share it.""" + from rich.syntax import Syntax + + sql = Syntax(sql_text or "", "sql", theme=_sql_syntax_theme(), line_numbers=line_numbers, + word_wrap=False, background_color="default", padding=(0, 1)) + sql_title = Text() + sql_title.append("sql", style=f"bold {theme.ACCENT}") + sql_title.append(f" · {QUERY_SQL_DIALECT}", style=theme.LABEL) + panel = Panel(sql, box=ROUNDED, border_style=theme.ACCENT, title=sql_title, + title_align="left", padding=(0, 1), expand=False) + return Padding(panel, (0, 0, 0, 2)) + + +def render_query_show(query: Any) -> None: + """The ``query show <name>`` view (stdout): a metadata card (``{name} · saved query`` + the FULL + wrapped description + ``created by {who} · created {date}``) then a line-numbered, + syntax-highlighted SQL box (``sql · {dialect}``, Rich ``Syntax`` on the brand palette, never + truncated/reflowed). No run footer.""" + desc = (query.description or "").strip() or "—" + meta = Text() + meta.append("created by ", style=theme.LABEL) + cb = query.created_by or "system" + meta.append(cb, style=theme.TEXT_DIM if cb == "system" else theme.TEXT) + meta.append(" · ", style=theme.FAINT) + meta.append("created ", style=theme.LABEL) + meta.append(_fmt_query_date_full(query.created_at), style=theme.TEXT_DIM) + + title = Text() + title.append(query.name or "-", style=f"bold {theme.ACCENT}") + title.append(" · saved query", style=theme.LABEL) + # Bound the card width so a long single-line description wraps (the SQL box below is + # content-width so its lines never reflow). + card_width = min(max(_stdout.width - 4, 40), 80) + card = Panel(Group(Text(desc, style=theme.TEXT_DIM), Text(), meta), box=ROUNDED, + border_style=theme.ACCENT, title=title, title_align="left", + padding=(0, 1), expand=False, width=card_width) + _stdout.print() + _stdout.print(Padding(card, (0, 0, 0, 2))) + _stdout.print(_query_sql_panel(query.sql_text or "")) + _stdout.print() + + +# ── query write flows (create / update / delete) — plain feedback + data cards ─ +# Design principle for the query family: BOXES carry data (the created/updated/preview cards + +# the sql box + list/show/run/schema panels); action FEEDBACK (confirm / warning / ✓ / ○ / ✗) +# is plain indented lines on stderr. (keys/users box their feedback — a deliberate query split.) + + +def _plain(*parts, console=None) -> None: + """Print one plain indented stderr line built from ``(text, style)`` segments (skips under + ``--quiet`` for non-error chrome; callers that must always show pass ``console``).""" + line = Text(" ") + for text, style in parts: + line.append(text, style=style) + (console or _stderr).print(line) + + +def query_not_found(name: str) -> None: + """Red ``error`` notice box (stderr): ``✗ no query named "<name>"`` + a dim hint to + ``fp query list``. The boxed query-feedback family — consistent with keys/users.""" + body = Text() + body.append("✗ ", style=f"bold {theme.ERROR}") + body.append('no query named "', style=theme.TEXT) + body.append(name, style=f"bold {theme.ACCENT}") + body.append('"', style=theme.TEXT) + hint = Text() + hint.append("run ", style=theme.FAINT) + hint.append("fp query list", style=theme.ACCENT) + hint.append(" to see saved queries", style=theme.FAINT) + _notice_box(Group(body, hint), color=theme.ERROR, title="error") + + +def query_exists(name: str) -> None: + """Red ``error`` notice box (stderr): ``✗ a query named <name> already exists`` + a dim hint.""" + body = Text() + body.append("✗ ", style=f"bold {theme.ERROR}") + body.append("a query named ", style=theme.TEXT) + body.append(name, style=f"bold {theme.ACCENT}") + body.append(" already exists", style=theme.TEXT) + hint = Text() + hint.append("pick a different name, or update it with ", style=theme.FAINT) + hint.append(f"fp query update {name}", style=theme.ACCENT) + _notice_box(Group(body, hint), color=theme.ERROR, title="error") + + +def query_failed(message: str, *, permission: bool = False) -> None: + """Red ``error`` notice box (stderr) for a failed ``query run``. A SQL/exec failure reads + ``✗ query failed — <server message>`` + a dim ``check your query and rerun it`` hint; a + permission error (``permission=True``) shows just the server message (no query hint).""" + msg = (message or "").strip() + if permission: + body = Text() + body.append("✗ ", style=f"bold {theme.ERROR}") + body.append(msg or "you don't have permission to run queries", style=theme.TEXT) + _notice_box(body, color=theme.ERROR, title="error") + return + head = Text() + head.append("✗ ", style=f"bold {theme.ERROR}") + if not msg or msg.lower() == "query failed": + head.append("query failed", style=theme.TEXT) + else: + head.append("query failed — ", style=theme.TEXT) + head.append(msg, style=theme.TEXT) + hint = Text("check your query and rerun it", style=theme.LABEL) + _notice_box(Group(head, hint), color=theme.ERROR, title="error") + + +def query_cancelled(tail: str) -> None: + """Faint ``cancelled`` notice box (stderr): ``○ <tail>`` (e.g. ``nothing deleted`` / + ``nothing changed``). Declining is a good outcome, not an error.""" + if _quiet: + return + body = Text() + body.append("○ ", style=theme.FAINT) + body.append(tail, style=theme.LABEL) + _notice_box(body, color=theme.FAINT, title="cancelled") + + +def confirm_line(headline: Text, consequence: Optional[Text] = None) -> bool: + """A plain (unboxed) confirm on stderr: the pre-built ``headline`` line, an optional dim + ``consequence`` line, then ``confirm? [y/N]`` (default NO). Returns the answer. Used by the + query delete/update flows (the query family keeps action prompts plain, not boxed).""" + _stderr.print() + _stderr.print(Text(" ") + headline) + if consequence is not None: + _stderr.print(Text(" ") + consequence) + return typer.confirm(_ansi(" confirm?", dim=True), default=False, err=True, prompt_suffix=" ") + + +def _query_card(query: Any, *, title_word: str, verb: str, old_name: Optional[str] = None) -> None: + """The shared green created/updated card (stderr) + SQL box + run-hint. Line 1 = the (new) + name (bold hero) + dim `` was {old}`` when renamed; line 2 = description (omitted if none); + line 3 = ``{verb} by you · just now``. ``title_word`` is ``query created`` / ``query updated``.""" + line1 = Text() + line1.append(query.name or "-", style=f"bold {theme.TEXT}") + if old_name and old_name != query.name: + line1.append(" was ", style=theme.FAINT) + line1.append(old_name, style=theme.FAINT) + rows = [line1] + desc = (query.description or "").strip() + if desc: + rows.append(Text(desc, style=theme.TEXT_DIM)) + line3 = Text() + line3.append(f"{verb} by ", style=theme.LABEL) + line3.append("you", style=theme.TEXT) + line3.append(" · ", style=theme.FAINT) + line3.append("just now", style=theme.LABEL) + rows.append(line3) + + card = Panel(Group(*rows), box=ROUNDED, border_style=theme.SUCCESS, + title=Text(title_word, style=f"bold {theme.SUCCESS}"), + title_align="left", padding=(0, 1), expand=False) + _stderr.print() + _stderr.print(Padding(card, (0, 0, 0, 2))) + # Numbered SQL box — identical to `query show`, so create/update/show read the same. No run + # footer, and no trailing blank (the SQL panel already carries its own left/below padding). + _stderr.print(_query_sql_panel(query.sql_text or "", line_numbers=True)) + + +def render_query_created(query: Any) -> None: + """The ``query create`` success view (stderr): GREEN ``query created`` card + SQL box + run-hint.""" + _query_card(query, title_word="query created", verb="created") + + +def render_query_updated(query: Any, *, old_name: Optional[str] = None) -> None: + """The ``query update`` success view (stderr): GREEN ``query updated`` card (` was {old}` when + renamed) + SQL box + run-hint (new name).""" + _query_card(query, title_word="query updated", verb="updated", old_name=old_name) + + +def render_query_delete_preview(query: Any) -> None: + """The ``query delete`` preview (stderr): an AMBER ``delete saved query`` box showing what's + about to be removed — name (ACCENT), one-line description, ``created by {who} · {date}`` — so + the operator can confirm it's the right query before the prompt.""" + line1 = Text(query.name or "-", style=f"bold {theme.ACCENT}") + desc = " ".join((query.description or "").split()) + width = min(max(_stdout.width - 4, 40), 80) + line2 = Text(_truncate(desc, width - 4) if desc else "—", style=theme.TEXT_DIM) + line3 = Text() + line3.append("created by ", style=theme.LABEL) + cb = query.created_by or "system" + line3.append(cb, style=theme.TEXT_DIM if cb == "system" else theme.TEXT) + line3.append(" · ", style=theme.FAINT) + line3.append(_fmt_query_date_full(query.created_at), style=theme.TEXT_DIM) + card = Panel(Group(line1, line2, line3), box=ROUNDED, border_style=theme.AMBER, + title=Text("delete saved query", style=f"bold {theme.AMBER}"), + title_align="left", padding=(0, 1), expand=False, width=width) + _stderr.print() + _stderr.print(Padding(card, (0, 0, 0, 2))) + + +def query_deleted(name: str) -> None: + """Green ``deleted`` notice box (stderr): ``✓ deleted saved query <name>``.""" + if _quiet: + return + body = Text() + body.append("✓ ", style=theme.SUCCESS) + body.append("deleted saved query ", style=theme.TEXT) + body.append(name, style=f"bold {theme.ACCENT}") + _notice_box(body, color=theme.SUCCESS, title="deleted") + + +def query_no_change() -> None: + """Faint ``no change`` notice box (stderr): ``○ query already matches``.""" + if _quiet: + return + body = Text() + body.append("○ ", style=theme.FAINT) + body.append("query already matches", style=theme.LABEL) + _notice_box(body, color=theme.FAINT, title="no change") + + +def confirm_query_delete() -> bool: + """Plain delete confirm (stderr): ``⚠ this permanently removes the query — it can't be + undone`` + ``confirm? [y/N]`` (the amber preview box was printed just above). Returns y/N.""" + h = Text() + h.append("⚠ ", style=f"bold {theme.AMBER}") + h.append("this permanently removes the query — it can't be undone", style=theme.LABEL) + return confirm_line(h) + + +def confirm_query_update(name: str, fields: str) -> bool: + """Plain update confirm (stderr): ``⚠ update saved query {name}?`` + ``this replaces its + {fields}`` + ``confirm? [y/N]`` (calm — update is reversible). Returns y/N.""" + h = Text() + h.append("⚠ ", style=f"bold {theme.AMBER}") + h.append("update saved query ", style=theme.TEXT) + h.append(name, style=f"bold {theme.ACCENT}") + h.append("?", style=theme.TEXT) + c = Text(f"this replaces its {fields}", style=theme.LABEL) + return confirm_line(h, c) + + +# ── query run (adaptive result renderer) ───────────────────────────────────── +# The result shape is unknown (any columns/types/row counts), so ONE renderer dispatches on +# shape: 0 rows → empty; 1×1 → scalar card; 1×N → vertical record; N rows → table. Values arrive +# as strings; the column `type` (ClickHouse type name) drives numeric alignment/colour, with a +# value heuristic fallback. NEVER keyed on column NAME (the schema can't be enumerated). +QUERY_RUN_ROW_CAP = 50 +_RUN_CELL_MAX = 48 # truncate a wide table cell to this many chars (keeps rows one line) +_NUMERIC_TYPE_RE = re.compile(r"int|float|decimal|numeric|double|real|^u?int", re.IGNORECASE) + + +def _is_numeric_type(type_str: Optional[str]) -> bool: + return bool(type_str) and bool(_NUMERIC_TYPE_RE.search(str(type_str))) + + +def _looks_numeric(value: Any) -> bool: + try: + float(str(value).replace(",", "")) + return True + except (TypeError, ValueError): + return False + + +def _col_is_numeric(col: dict, rows: Sequence[Sequence[Any]], i: int) -> bool: + """Whether column ``i`` is numeric — by its declared ``type`` if present, else a value + heuristic on the first non-null cell.""" + t = col.get("type") + if t: + return _is_numeric_type(t) + for r in rows: + v = r[i] if i < len(r) else None + if v is not None: + return _looks_numeric(v) + return False + + +def _run_value(value: Any, numeric: bool) -> Text: + """One result cell → styled Text: null = dim italic ``null``; numeric = pink (integers get + thousands separators, floats keep their string precision); else TEXT.""" + if value is None: + return Text("null", style=f"italic {theme.FAINT}") + s = str(value) + if numeric: + try: + s = f"{int(s):,}" # thousands separators for integers + except ValueError: + pass # float/decimal → keep the original precision + return Text(s, style=theme.PINK) + return Text(s, style=theme.TEXT) + + +def _run_title(name: str, n: int, ms: Optional[int]) -> Text: + t = Text() + t.append(name, style=f"bold {theme.ACCENT}") + t.append(f" · {n} {'row' if n == 1 else 'rows'}", style=theme.LABEL) + if ms is not None: + t.append(f" · {ms}ms", style=theme.LABEL) + return t + + +def _run_panel(body: Any, title: Text) -> None: + panel = Panel(body, box=ROUNDED, border_style=theme.ACCENT, title=title, + title_align="left", padding=(0, 1), expand=False) + _stdout.print() + _stdout.print(Padding(panel, (0, 0, 0, 2))) + + +def render_query_result(name: str, result: Any, *, row_cap: int = QUERY_RUN_ROW_CAP, + show_all: bool = False) -> None: + """Adaptive ``query run`` result renderer (stdout): dispatches on result shape — 0 rows → + empty card, 1×1 → scalar stat card, 1×N → vertical key/value record, N rows → boxed table + (numeric cols right-aligned + pink, others left + TEXT, null dim italic, rows capped to + ``row_cap`` unless ``show_all``, wide cells truncated, overflow columns dropped). Title + ``{name} · {n} rows · {ms}ms``.""" + cols = list(result.columns or []) + rows = list(result.rows or []) + n, ncols = len(rows), len(cols) + ms = getattr(result, "elapsed_ms", None) + title = _run_title(name, n, ms) + + if n == 0: + _run_panel(Text("no rows returned", style=theme.TEXT_DIM), title) + return + + if n == 1 and ncols == 1: # scalar stat card + numeric = _col_is_numeric(cols[0], rows, 0) + val = _run_value(rows[0][0], numeric) + body = Text() + body.append(val.plain, style=f"bold {theme.PINK if numeric else theme.TEXT}") + body.append(" ") + body.append(str(cols[0].get("name", "")), style=theme.LABEL) + _run_panel(body, title) + return + + if n == 1: # single record → vertical key/value card + kv = Table(box=None, pad_edge=False, show_header=False) + kv.add_column(style=theme.LABEL, no_wrap=True) + kv.add_column() + for i, c in enumerate(cols): + v = rows[0][i] if i < len(rows[0]) else None + kv.add_row(str(c.get("name", "")), _run_value(v, _col_is_numeric(c, rows, i))) + _run_panel(kv, title) + return + + _run_table(title, cols, rows, row_cap=row_cap, show_all=show_all, total=n, ms=ms) + + +def _run_table(title: Text, cols: list, rows: list, *, row_cap: int, show_all: bool, + total: int, ms: Optional[int]) -> None: + numeric = [_col_is_numeric(cols[i], rows, i) for i in range(len(cols))] + shown = rows if show_all else rows[:row_cap] + + # Column display widths (header vs capped cell content), then drop overflow columns from the + # right until the table fits the terminal — keep the leftmost (most identifying) columns. + def _cellw(i: int) -> int: + w = len(str(cols[i].get("name", ""))) + for r in shown: + v = r[i] if i < len(r) else None + w = max(w, min(len(_run_value(v, numeric[i]).plain), _RUN_CELL_MAX)) + return w + widths = [_cellw(i) for i in range(len(cols))] + avail = max(20, _stdout.width - 6) + keep = len(cols) + while keep > 1 and sum(widths[:keep]) + 2 * keep > avail: + keep -= 1 + hidden = len(cols) - keep + + table = Table(box=SIMPLE_HEAD, border_style=theme.THIN_RULE, pad_edge=False, show_edge=False, + show_header=True, header_style=theme.LABEL, expand=False, padding=(0, 2, 0, 0)) + for i in range(keep): + table.add_column(str(cols[i].get("name", "")), justify="right" if numeric[i] else "left", + no_wrap=True, overflow="ellipsis", max_width=_RUN_CELL_MAX) + table.add_row(*([""] * keep)) # spacer under the header rule + for r in shown: + table.add_row(*[_run_value(r[i] if i < len(r) else None, numeric[i]) for i in range(keep)]) + _run_panel(table, title) + + if not _quiet: + foot = Text(" ") + if not show_all and total > len(shown): + foot.append(f"showing {len(shown):,} of {total:,} rows", style=theme.LABEL) + else: + foot.append(f"{total:,} {'row' if total == 1 else 'rows'}", style=theme.LABEL) + foot.append(" · ", style=theme.FAINT) + foot.append(f"{len(cols)} columns", style=theme.LABEL) + if ms is not None: + foot.append(" · ", style=theme.FAINT) + foot.append(f"{ms}ms", style=theme.LABEL) + if hidden: + foot.append(" · ", style=theme.FAINT) + foot.append(f"{hidden} columns hidden", style=theme.AMBER) + if (not show_all and total > len(shown)) or hidden: + foot.append(" · ", style=theme.FAINT) + foot.append("fp --json query run …", style=theme.ACCENT) + foot.append(" for all", style=theme.FAINT) + _stderr.print(foot) + _stderr.print() + + +# ── query schema (boxed, table-grouped, typed colours) ─────────────────────── + + +def _schema_type_color(base: str) -> str: + """Colour a base type by category (family, never by column name): numeric pink, string green, + uuid/timestamp blue, bool amber, else neutral TEXT.""" + b = base.lower() + if re.search(r"int|float|decimal|numeric|double|real", b): + return theme.PINK + if "uuid" in b: + return theme.BLUE + if re.search(r"time|date", b): + return theme.BLUE + if "bool" in b: + return theme.AMBER + if re.search(r"str|text|char", b): + return theme.SUCCESS + return theme.TEXT + + +def _schema_type_cell(type_str: str) -> Text: + """``string?`` → green ``string`` + dim italic `` ?`` (the ``?`` = nullable, split from the + base so 'what type' and 'can be null' read separately).""" + nullable = type_str.endswith("?") + base = type_str[:-1] if nullable else type_str + t = Text(base, style=_schema_type_color(base)) + if nullable: + t.append(" ?", style=f"italic {theme.TEXT_DIM}") + return t + + +def render_query_schema(data: dict) -> None: + """The ``query schema`` view (stdout): a boxed ``schema · {db} · {t} tables · {c} columns`` + panel with columns ``table · column · type``. The table name prints ONCE per group (ACCENT + bold on the first row, blank on repeat) with a spacer row between groups; the type is coloured + by category + a dim ``?`` for nullable. All tables/columns shown (a schema is a reference).""" + db = data.get("schema", "") + tables = list(data.get("tables", []) or []) + total_cols = sum(len(t.get("columns", []) or []) for t in tables) + + rows = [] + for ti, tbl in enumerate(tables): + tname = str(tbl.get("name", "")) + columns = tbl.get("columns", []) or [] + for ci, col in enumerate(columns): + if ci == 0 and ti > 0: + rows.append([Text(""), Text(""), Text("")]) # blank spacer between table groups + table_cell = Text(tname, style=f"bold {theme.ACCENT}") if ci == 0 else Text("") + rows.append([table_cell, Text(str(col.get("name", "")), style=theme.TEXT), + _schema_type_cell(str(col.get("type", "")))]) + + t_word = "table" if len(tables) == 1 else "tables" + c_word = "column" if total_cols == 1 else "columns" + title = Text() + title.append("schema", style=f"bold {theme.ACCENT}") + for part in ([db] if db else []) + [f"{len(tables)} {t_word}", f"{total_cols} {c_word}"]: + title.append(f" · {part}", style=theme.LABEL) + render_list_panel("schema", header=["table", "column", "type"], rows=rows, days=set(), + order=None, empty_message="no schema available", title=title) + + +def schema_footer(ntables: int, ncols: int) -> None: + """Dim legend under the schema box (stderr): counts + a colour key (``int string + uuid/timestamp``) + ``? nullable``, so the type colours are self-explaining.""" + if _quiet: + return + line = Text(" ") + line.append(f"{ntables} {'table' if ntables == 1 else 'tables'}", style=theme.LABEL) + line.append(" · ", style=theme.FAINT) + line.append(f"{ncols} {'column' if ncols == 1 else 'columns'}", style=theme.LABEL) + line.append(" · ", style=theme.FAINT) + line.append("int", style=theme.PINK) + line.append(" ") + line.append("string", style=theme.SUCCESS) + line.append(" ") + line.append("uuid/timestamp", style=theme.BLUE) + line.append(" · ", style=theme.FAINT) + line.append("?", style=f"italic {theme.TEXT_DIM}") + line.append(" nullable", style=theme.LABEL) + _stderr.print(line) + _stderr.print() + + +def schema_table_not_found(name: str, available: Sequence[str]) -> None: + """Red ``error`` notice box (stderr): ``✗ no table named "<name>"`` + a dim list of the + available tables.""" + body = Text() + body.append("✗ ", style=f"bold {theme.ERROR}") + body.append('no table named "', style=theme.TEXT) + body.append(name, style=f"bold {theme.ACCENT}") + body.append('"', style=theme.TEXT) + parts = [body] + if available: + avail = Text() + avail.append("available: ", style=theme.FAINT) + avail.append(", ".join(available), style=theme.TEXT_DIM) + parts.append(avail) + _notice_box(Group(*parts), color=theme.ERROR, title="error") + + +# ── alerts (list box + show cards) ─────────────────────────────────────────── + +# Severity → colour. A small fixed enum with real urgency meaning, so a value→colour map is +# safe; unknown severities fall back to neutral dim (never crash / guess a colour). +_SEVERITY_COLORS = {"critical": theme.ERROR, "warning": theme.AMBER, "info": theme.TEXT_DIM} + + +def humanize_secs(n: Optional[int]) -> str: + """A ``*_secs`` value → a compact human duration: whole units when divisible (300→``5m``, + 900→``15m``, 3600→``1h``, 86400→``1d``), else seconds (``45s``). ``-`` if missing.""" + if n is None: + return "-" + n = int(n) + if n and n % 86400 == 0: + return f"{n // 86400}d" + if n and n % 3600 == 0: + return f"{n // 3600}h" + if n and n % 60 == 0: + return f"{n // 60}m" + return f"{n}s" + + +def _age_compact(ts: Optional[str]) -> Optional[str]: + """Compact relative age for the alerts ``last alert`` column: ``45s ago`` / ``2m ago`` / + ``1h ago`` / ``3d ago``; ``None`` if the timestamp is missing/unparsable (caller → ``never``).""" + dt = _parse_iso(ts or "") + if dt is None: + return None + secs = max(0.0, (datetime.now(timezone.utc) - dt).total_seconds()) + if secs < 60: + return f"{int(secs)}s ago" + if secs < 3600: + return f"{int(secs // 60)}m ago" + if secs < 86400: + return f"{int(secs // 3600)}h ago" + return f"{int(secs // 86400)}d ago" + + +def _anchor_compact(ts: Optional[str]) -> str: + """A schedule anchor as ``2026-07-22 09:00 UTC``. The anchor is a PHASE, not a + deadline — what an operator needs to read off it is the time of day runs land + on — so it renders absolute and in UTC (audits are UTC end to end), never as a + relative age. Falls back to the raw string if it doesn't parse, so a value the + server accepted is never hidden.""" + dt = _parse_iso(ts or "") + if dt is None: + return str(ts or "-") + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + + +def _severity_cell(severity: str, *, muted: bool = False) -> Text: + """The severity as a colour-coded word (critical red / warning amber / info+unknown neutral). + ``muted`` dims it (disabled rows). Under NO_COLOR a ``!`` marks critical so it stays visible.""" + sev = severity or "-" + if _no_color and sev == "critical": + sev += "!" # keep the critical marker even on a muted (disabled) row — colour is gone + if muted: + return Text(sev, style=theme.FAINT) + color = _SEVERITY_COLORS.get(severity or "-", theme.TEXT_DIM) + return Text(sev, style=color) + + +def _alert_status_cell(enabled: bool, *, muted: bool = False) -> Text: + """Alert status from ``enabled``: ``● on`` (green) / ``○ off`` (dim) — same dot vocabulary as + keys/users. In a dimmed (disabled) row the whole cell is muted.""" + t = Text() + if enabled: + c = theme.FAINT if muted else theme.SUCCESS + t.append("● ", style=c) + t.append("on", style=c) + else: + c = theme.FAINT if muted else theme.TEXT_DIM + t.append("○ ", style=c) + t.append("off", style=c) + return t + + +def render_alerts(alerts: Sequence[Any], *, show_id: bool = False) -> None: + """The ``alerts list`` view (stdout): an ACCENT panel titled ``alerts · {n} · newest first`` with + columns ``created · name · by · trigger · severity · last alert``. ``by`` is the actual creator + (email); severity is a colour-coded word; ``last alert`` is the humanized age of + ``last_attempted_at`` (``never`` if it has never been evaluated — e.g. a disabled alert). + Disabled alerts dim entirely so live ones dominate (the on/off split lives in the footer). + ``name`` is the handle; raw id hidden unless ``show_id``. The ``id``/``open_incidents``/raw ISO + live only in ``--json``.""" + items = sorted(alerts, key=lambda a: a.created_at or "", reverse=True) # newest first + parsed = [_parse_iso(a.created_at) for a in items] + multi_year = len({p.year for p in parsed if p is not None}) > 1 + + header = (["id"] if show_id else []) + ["created", "name", "by", "trigger", "severity", "last alert"] + rows = [] + for a in items: + disabled = not a.enabled + name_style = theme.TEXT_DIM if disabled else theme.TEXT + dim = theme.FAINT if disabled else theme.TEXT_DIM + by = a.created_by or "-" # the actual creator (email), not "you" + age = _age_compact(a.last_attempted_at) + last = Text(age, style=dim) if age else Text("never", style=theme.FAINT) + row = [Text(_short_id(a.id or "-"), style=dim)] if show_id else [] + row += [ + Text(_fmt_user_joined(a.created_at, multi_year), style=dim), + Text(a.name or "-", style=name_style), + Text(by, style=dim), + Text(a.trigger_kind or "-", style=dim), + _severity_cell(a.severity, muted=disabled), + last, + ] + rows.append(row) + + title = Text() + title.append("alerts", style=f"bold {theme.ACCENT}") + title.append(f" · {len(items)} · newest first", style=theme.LABEL) + render_list_panel("alerts", header=header, rows=rows, days=set(), order=None, + empty_message="no alerts", title=title) + + +def alerts_footer(alerts: Sequence[Any]) -> None: + """Distribution summary under the alerts box (stderr): ``{total} alerts · {n} on · {m} off · + {c} critical {w} warning`` — counts in their colours, each severity segment present only when + that severity actually appears.""" + if _quiet: + return + total = len(alerts) + on = sum(1 for a in alerts if a.enabled) + off = total - on + line = Text(" ") + line.append(f"{total} alerts", style=theme.LABEL) + if on: + line.append(" · ", style=theme.FAINT) + line.append(f"{on} on", style=theme.SUCCESS) + if off: + line.append(" · ", style=theme.FAINT) + line.append(f"{off} off", style=theme.FAINT) + sev_counts = {} + for a in alerts: + sev_counts[a.severity] = sev_counts.get(a.severity, 0) + 1 + sev_segs = [s for s in ("critical", "warning", "info") if sev_counts.get(s)] + if sev_segs: + line.append(" · ", style=theme.FAINT) + for i, s in enumerate(sev_segs): + if i: + line.append(" ") + line.append(f"{sev_counts[s]} {s}", style=_SEVERITY_COLORS.get(s, theme.TEXT_DIM)) + _stderr.print(line) + _stderr.print() + + +# ── alerts show (stacked cards: identity · trigger(per-kind) · evaluation · channels) ── + + +def _fmt_alert_num(v: Any) -> str: + """A trigger-spec number → display string (``0.8``, ``50``); ``50.0`` → ``50``.""" + if isinstance(v, bool): + return str(v) + if isinstance(v, float) and v.is_integer(): + return str(int(v)) + return str(v) + + +def _alert_card(title: Text, body: Any) -> None: + """Print one ACCENT card (stdout) in the alerts-show stack, with a blank line above it.""" + panel = Panel(body, box=ROUNDED, border_style=theme.ACCENT, title=title, title_align="left", + padding=(0, 1), expand=False) + _stdout.print() + _stdout.print(Padding(panel, (0, 0, 0, 2))) + + +def _trig_metric_threshold(spec: dict): + s = Text() + s.append("fire when ", style=theme.TEXT) + s.append(str(spec.get("metric", "?")), style=theme.ACCENT) + s.append(" ") + s.append(str(spec.get("op", "?")), style=theme.ERROR) + s.append(" ") + s.append(_fmt_alert_num(spec.get("value")), style=theme.PINK) + s.append(" over ", style=theme.TEXT) + s.append(humanize_secs(spec.get("window_secs")), style=theme.BLUE) + lines = [s] + filt = spec.get("filter") or {} + present = [(k, v) for k, v in filt.items() if v not in (None, "")] + if present: + f = Text() + f.append("filter".ljust(10), style=theme.LABEL) + for i, (k, v) in enumerate(present): + if i: + f.append(" · ", style=theme.FAINT) + f.append(f"{k} = ", style=theme.LABEL) + f.append(str(v), style=theme.TEXT) + lines.append(f) + return lines, None + + +def _trig_custom_sql(spec: dict): + s = Text() + s.append("fire when query ", style=theme.TEXT) + s.append(str(spec.get("query_name") or spec.get("query_id") or "?"), style=theme.ACCENT) + s.append(" ") + s.append(str(spec.get("op", "?")), style=theme.ERROR) + s.append(" ") + s.append(_fmt_alert_num(spec.get("value")), style=theme.PINK) + s.append(" rows", style=theme.TEXT) + return [s], spec.get("sql") + + +def _trig_evaluation_score(spec: dict): + s = Text() + s.append("fire when ", style=theme.TEXT) + s.append(str(spec.get("score_key", "?")), style=theme.ACCENT) + s.append(" ") + s.append(str(spec.get("op", "?")), style=theme.ERROR) + s.append(" ") + s.append(_fmt_alert_num(spec.get("value")), style=theme.PINK) + if spec.get("min_count") is not None: + s.append(f" (min {spec['min_count']})", style=theme.LABEL) + s.append(" over ", style=theme.TEXT) + s.append(humanize_secs(spec.get("window_secs")), style=theme.BLUE) + lines = [s] + if spec.get("environment"): + e = Text() + e.append("environment".ljust(13), style=theme.LABEL) + e.append(str(spec["environment"]), style=theme.TEXT) + lines.append(e) + return lines, None + + +def _trig_per_event(spec: dict): + s = Text() + s.append("fire on ", style=theme.TEXT) + s.append(str(spec.get("event_type", "?")), style=theme.ACCENT) + s.append(" events within ", style=theme.TEXT) + s.append(humanize_secs(spec.get("lookback_secs")), style=theme.BLUE) + lines = [s] + for label, key in (("environment", "environment"), ("tool_name", "tool_name"), + ("error_type", "error_type"), ("agent_id", "agent_id")): + v = spec.get(key) + if v not in (None, ""): + ln = Text() + ln.append(label.ljust(13), style=theme.LABEL) + ln.append(str(v), style=theme.TEXT) + lines.append(ln) + if spec.get("message_contains"): + ln = Text() + ln.append("message ~".ljust(13), style=theme.LABEL) + ln.append(f'"{spec["message_contains"]}"', style=theme.TEXT) + lines.append(ln) + return lines, None + + +def _trig_eval_compound(spec: dict): + comb = spec.get("combinator") + if isinstance(comb, dict) and "at_least" in comb: + phrase = f"at least {comb['at_least']}" + elif comb in ("any", "all"): + phrase = comb + else: + phrase = str(comb) + s = Text() + s.append("fire when ", style=theme.TEXT) + s.append(phrase, style=f"bold {theme.TEXT}") + s.append(" of these over ", style=theme.TEXT) + s.append(humanize_secs(spec.get("window_secs")), style=theme.BLUE) + s.append(":", style=theme.TEXT) + lines = [s] + for cond in spec.get("conditions") or []: + ln = Text(" ") + ln.append(str(cond.get("score_key", "?")), style=theme.ACCENT) + ln.append(" ") + ln.append(str(cond.get("op", "?")), style=theme.ERROR) + ln.append(" ") + ln.append(_fmt_alert_num(cond.get("value")), style=theme.PINK) + lines.append(ln) + trailing = Text() + if spec.get("min_count") is not None: + trailing.append("min count ", style=theme.LABEL) + trailing.append(str(spec["min_count"]), style=theme.TEXT) + if spec.get("environment"): + if trailing.plain: + trailing.append(" · ", style=theme.FAINT) + trailing.append("environment ", style=theme.LABEL) + trailing.append(str(spec["environment"]), style=theme.TEXT) + if trailing.plain: + lines.append(trailing) + return lines, None + + +def _trig_unknown(spec: dict): + """Graceful fallback for a future/unknown trigger kind: a key/value dump (numbers coloured, + nested objects shown compact) — never crash, never dump raw JSON wholesale.""" + lines = [] + for k, v in (spec or {}).items(): + ln = Text() + ln.append(str(k).ljust(16), style=theme.LABEL) + if isinstance(v, (int, float)) and not isinstance(v, bool): + ln.append(_fmt_alert_num(v), style=theme.PINK) + elif isinstance(v, (dict, list)): + ln.append(_json.dumps(v, ensure_ascii=False), style=theme.TEXT_DIM) + else: + ln.append(str(v), style=theme.TEXT) + lines.append(ln) + return lines or [Text("(no spec)", style=theme.TEXT_DIM)], None + + +_TRIGGER_PARSERS = { + "metric_threshold": _trig_metric_threshold, + "custom_sql": _trig_custom_sql, + "evaluation_score": _trig_evaluation_score, + "per_event": _trig_per_event, + "eval_compound": _trig_eval_compound, +} + + +def _alert_trigger_body(kind: str, spec: Optional[dict]): + """Parse ``trigger_spec`` into a card body, dispatched on ``trigger_kind`` (with a graceful + key/value fallback for unknown kinds). ``custom_sql``'s SQL is rendered via the shared + ``Syntax`` box inside the card.""" + lines, sql = _TRIGGER_PARSERS.get(kind, _trig_unknown)(spec or {}) + if sql: + from rich.syntax import Syntax + syn = Syntax(sql, "sql", theme=_sql_syntax_theme(), line_numbers=False, + word_wrap=False, background_color="default") + return Group(*lines, Text(), syn) + return Group(*lines) + + +def _is_default_key(key: Optional[str]) -> bool: + """A channel setting-key is on the org DEFAULT when it's absent or ``alerts.``-prefixed + (e.g. ``alerts.slack_default_webhook``); a bare entered key/url is CUSTOM.""" + return key is None or str(key).startswith("alerts.") + + +def _alert_channels_body(channels: Optional[list]): + """Parse ``channels`` into ``(all_default, table)``: one row per channel with a DIM ITALIC + ``default …`` descriptor where it inherits org settings, or the entered value in GREEN where + overridden (mixed supported). Empty ``[]`` → the full default set (slack/webhook/email).""" + dim_it = lambda s: Text(s, style=f"italic {theme.TEXT_DIM}") # noqa: E731 + rows = [] + all_default = True + if not channels: + rows = [("slack", dim_it("default webhook")), + ("webhook", dim_it("default url + signing secret")), + ("email", dim_it("default recipients"))] + else: + for ch in channels: + kind = str(ch.get("kind", "?")) + if kind == "slack": + key = ch.get("webhook_setting_key") + if _is_default_key(key): + desc = dim_it("default webhook") + else: + desc = Text(str(key), style=theme.SUCCESS) + all_default = False + elif kind == "webhook": + desc = Text() + url, sec = ch.get("url_setting_key"), ch.get("secret_setting_key") + if _is_default_key(url): + desc.append("default url", style=f"italic {theme.TEXT_DIM}") + else: + desc.append(str(url), style=theme.SUCCESS) + all_default = False + desc.append(" + ", style=theme.FAINT) + if _is_default_key(sec): + desc.append("signing secret", style=f"italic {theme.TEXT_DIM}") + else: + desc.append(str(sec), style=theme.SUCCESS) + all_default = False + elif kind == "email": + rec = ch.get("recipients") + if not rec: + desc = dim_it("default recipients") + else: + desc = Text(", ".join(rec), style=theme.SUCCESS) + all_default = False + elif kind == "dashboard": + desc = dim_it("in-app") + else: + desc = dim_it(_json.dumps(ch, ensure_ascii=False)) + rows.append((kind, desc)) + + table = Table(box=None, pad_edge=False, show_header=False) + table.add_column(style=theme.TEXT, no_wrap=True) + table.add_column() + for kind, desc in rows: + table.add_row(kind, desc) + return all_default, table + + +def _alert_status_inline(enabled: bool) -> Text: + """The ``● enabled``/``○ disabled`` fragment used in the alert identity/card lines.""" + t = Text() + if enabled: + t.append("● ", style=theme.SUCCESS) + t.append("enabled", style=theme.SUCCESS) + else: + t.append("○ ", style=theme.TEXT_DIM) + t.append("disabled", style=theme.TEXT_DIM) + return t + + +def _alert_identity_line(alert: Any, *, with_open: bool = True) -> Text: + """``{severity} · {● enabled/○ disabled} · {trigger_kind}`` (+ ``· {N} open incidents`` when + ``with_open``, the count red if > 0). Shared by show + created/updated cards.""" + line = Text() + line.append_text(_severity_cell(alert.severity)) + line.append(" · ", style=theme.FAINT) + line.append_text(_alert_status_inline(alert.enabled)) + line.append(" · ", style=theme.FAINT) + line.append(alert.trigger_kind or "-", style=theme.TEXT_DIM) + if with_open: + oi = alert.open_incidents + line.append(" · ", style=theme.FAINT) + line.append(str(oi), style=f"bold {theme.ERROR}" if oi > 0 else theme.LABEL) + line.append(f" open incident{'' if oi == 1 else 's'}", style=theme.LABEL) + return line + + +def _alert_config_cards(alert: Any) -> None: + """Print the trigger / evaluation / channels cards (ACCENT) — shared by show + created/updated. + Returns nothing; the identity card + footer are the caller's (they differ per flow).""" + # trigger (parsed per kind) + t2 = Text() + t2.append("trigger", style=f"bold {theme.ACCENT}") + t2.append(f" · {(alert.trigger_kind or '').replace('_', ' ')}", style=theme.LABEL) + _alert_card(t2, _alert_trigger_body(alert.trigger_kind, alert.trigger_spec)) + + # evaluation + ev = Text() + ev.append("window ", style=theme.LABEL) + ev.append(str(alert.eval_window), style=theme.TEXT) + ev.append(" · ", style=theme.FAINT) + ev.append("min breaches ", style=theme.LABEL) + ev.append(str(alert.min_breaches), style=theme.TEXT) + ev.append(" · ", style=theme.FAINT) + ev.append("checks every ", style=theme.LABEL) + ev.append(humanize_secs(alert.eval_interval_secs), style=theme.TEXT) + _alert_card(Text("evaluation", style=f"bold {theme.ACCENT}"), ev) + + # channels (default vs custom) + all_default, ch_body = _alert_channels_body(alert.channels) + t4 = Text() + t4.append("channels", style=f"bold {theme.ACCENT}") + t4.append(" · ", style=theme.FAINT) + t4.append("default" if all_default else "custom", style=theme.LABEL) + _alert_card(t4, ch_body) + + +def render_alert_show(alert: Any) -> None: + """The ``alerts show <name>`` view (stdout): a stack of cards — identity (severity · status · + kind · open incidents), ``trigger`` (parsed per ``trigger_kind``), ``evaluation`` (window / + min breaches / interval), and ``channels`` (default vs custom) — then a ``--json`` footer.""" + _alert_card(Text(alert.name or "-", style=f"bold {theme.ACCENT}"), _alert_identity_line(alert)) + _alert_config_cards(alert) + if not _quiet: + _stderr.print() + + +def _render_alert_write_result(alert: Any, *, verb: str, old_name: Optional[str] = None) -> None: + """Shared ``alerts create``/``update`` success view (stdout): a GREEN ``alert {verb}`` card + (name hero + ` was {old}` on rename + identity line + ``{verb} by you · just now``) then the + same trigger/evaluation/channels config cards as ``show``, and a dim ``alerts show`` pointer.""" + line1 = Text() + line1.append(alert.name or "-", style=f"bold {theme.TEXT}") + if old_name and old_name != alert.name: + line1.append(" was ", style=theme.FAINT) + line1.append(old_name, style=theme.FAINT) + line2 = _alert_identity_line(alert, with_open=False) + line3 = Text() + line3.append(f"{verb} by ", style=theme.LABEL) + line3.append("you", style=theme.TEXT) + line3.append(" · ", style=theme.FAINT) + line3.append("just now", style=theme.LABEL) + card = Panel(Group(line1, line2, line3), box=ROUNDED, border_style=theme.SUCCESS, + title=Text(f"alert {verb}", style=f"bold {theme.SUCCESS}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print() + _stdout.print(Padding(card, (0, 0, 0, 2))) + _alert_config_cards(alert) + if not _quiet: + _stderr.print() + + +def render_alert_created(alert: Any) -> None: + """The ``alerts create`` success view (stdout): GREEN ``alert created`` card + config cards.""" + _render_alert_write_result(alert, verb="created") + + +def render_alert_updated(alert: Any, *, old_name: Optional[str] = None) -> None: + """The ``alerts update`` success view (stdout): GREEN ``alert updated`` card (` was {old}` on + rename) + config cards showing the new state.""" + _render_alert_write_result(alert, verb="updated", old_name=old_name) + + +def confirm_alert_update(name: str) -> bool: + """Plain update confirm (stderr): ``⚠ update alert {name}?`` + ``this replaces its definition`` + + ``confirm? [y/N]`` (calm — update is reversible). Returns the answer.""" + h = Text() + h.append("⚠ ", style=f"bold {theme.AMBER}") + h.append("update alert ", style=theme.TEXT) + h.append(name, style=f"bold {theme.ACCENT}") + h.append("?", style=theme.TEXT) + c = Text("this replaces the alert's definition", style=theme.LABEL) + return confirm_line(h, c) + + +def alert_exists(name: str) -> None: + """Red ``error`` notice box (stderr): ``✗ an alert named <name> already exists`` + a dim hint.""" + body = Text() + body.append("✗ ", style=f"bold {theme.ERROR}") + body.append('an alert named "', style=theme.TEXT) + body.append(name, style=f"bold {theme.ACCENT}") + body.append('" already exists', style=theme.TEXT) + hint = Text() + hint.append("pick a different name, or update it with ", style=theme.FAINT) + hint.append(f"fp alerts update {name}", style=theme.ACCENT) + _notice_box(Group(body, hint), color=theme.ERROR, title="error") + + +def confirm_alert_test(name: str) -> bool: + """Plain test confirm (stderr): ``⚠ send a test notification for {name}?`` + ``it delivers a + sample alert to the configured channels`` + ``confirm? [y/N]``. Returns the answer.""" + h = Text() + h.append("⚠ ", style=f"bold {theme.AMBER}") + h.append("send a test notification for ", style=theme.TEXT) + h.append(name, style=f"bold {theme.ACCENT}") + h.append("?", style=theme.TEXT) + c = Text("it delivers a sample alert to the configured channels", style=theme.LABEL) + return confirm_line(h, c) + + +def alert_test_sent(name: str, channel_kinds: Sequence[str]) -> None: + """Green ``test sent`` notice box (stderr): ``✓ test notification sent for {name}`` + a dim + ``dispatched to {kinds}`` line + an honest note that delivery isn't confirmed (the server's + test always returns ok regardless of actual delivery — see issue #183).""" + if _quiet: + return + head = Text() + head.append("✓ ", style=theme.SUCCESS) + head.append("test notification sent for ", style=theme.TEXT) + head.append(name, style=f"bold {theme.ACCENT}") + rows = [head] + if channel_kinds: + line = Text() + line.append("dispatched to ", style=theme.FAINT) + for i, k in enumerate(channel_kinds): + if i: + line.append(" · ", style=theme.FAINT) + line.append(k, style=theme.TEXT_DIM) + rows.append(line) + rows.append(Text("delivery isn't confirmed — verify it arrived in each channel", style=theme.FAINT)) + _notice_box(Group(*rows), color=theme.SUCCESS, title="test sent") + + +def alert_not_found(name: str) -> None: + """Red ``error`` notice box (stderr): ``✗ no alert named "<name>"`` + a dim ``alerts list`` hint.""" + body = Text() + body.append("✗ ", style=f"bold {theme.ERROR}") + body.append('no alert named "', style=theme.TEXT) + body.append(name, style=f"bold {theme.ACCENT}") + body.append('"', style=theme.TEXT) + hint = Text() + hint.append("run ", style=theme.FAINT) + hint.append("fp alerts list", style=theme.ACCENT) + hint.append(" to see alerts", style=theme.FAINT) + _notice_box(Group(body, hint), color=theme.ERROR, title="error") + + +def cancelled_plain(tail: str) -> None: + """Plain calm cancel line (stderr): ``○ cancelled — <tail>`` (shared by query/alerts deletes).""" + if _quiet: + return + _stderr.print() + _plain(("○ ", theme.FAINT), ("cancelled — ", theme.LABEL), (tail, theme.LABEL)) + + +def render_alert_delete_preview(alert: Any) -> None: + """The ``alerts delete`` preview (stderr): an AMBER ``delete alert`` box — name + severity · + trigger · status + ``{n} open incidents`` (red if > 0, since deleting orphans them).""" + line1 = Text(alert.name or "-", style=f"bold {theme.ACCENT}") + line2 = Text() + line2.append_text(_severity_cell(alert.severity)) + line2.append(" · ", style=theme.FAINT) + line2.append(alert.trigger_kind or "-", style=theme.TEXT_DIM) + line2.append(" · ", style=theme.FAINT) + line2.append_text(_alert_status_cell(alert.enabled)) + line3 = Text() + oi = alert.open_incidents + line3.append(str(oi), style=f"bold {theme.ERROR}" if oi > 0 else theme.TEXT_DIM) + line3.append(f" open incident{'' if oi == 1 else 's'}", style=theme.LABEL) + card = Panel(Group(line1, line2, line3), box=ROUNDED, border_style=theme.AMBER, + title=Text("delete alert", style=f"bold {theme.AMBER}"), + title_align="left", padding=(0, 1), expand=False) + _stderr.print() + _stderr.print(Padding(card, (0, 0, 0, 2))) + + +def confirm_alert_delete(open_incidents: int) -> bool: + """Plain delete confirm (stderr): ``⚠ this permanently removes the alert …`` (notes orphaned + open incidents when any) + ``confirm? [y/N]`` (the amber preview box was printed just above).""" + h = Text() + h.append("⚠ ", style=f"bold {theme.AMBER}") + if open_incidents > 0: + h.append(f"this permanently removes the alert — its {open_incidents} open " + f"incident{'' if open_incidents == 1 else 's'} will be orphaned", style=theme.LABEL) + else: + h.append("this permanently removes the alert — it can't be undone", style=theme.LABEL) + return confirm_line(h) + + +def alert_deleted(name: str) -> None: + """Green ``deleted`` notice box (stderr): ``✓ deleted alert <name>``.""" + if _quiet: + return + body = Text() + body.append("✓ ", style=theme.SUCCESS) + body.append("deleted alert ", style=theme.TEXT) + body.append(name, style=f"bold {theme.ACCENT}") + _notice_box(body, color=theme.SUCCESS, title="deleted") + + +# ── usage (current billing-window summary) ────────────────────────────────── + +def render_usage(data: dict) -> None: + """Render current-window usage as a hierarchy, not an undifferentiated metric list.""" + window = data.get("window") if isinstance(data.get("window"), dict) else {} + usage = data.get("usage") if isinstance(data.get("usage"), dict) else {} + + def parsed(raw: Any) -> Optional[datetime]: + dt = _parse_iso(str(raw or "")) + return dt.astimezone(timezone.utc) if dt else None + + def day(raw: Any) -> str: + dt = _parse_iso(str(raw or "")) + return dt.astimezone(timezone.utc).strftime("%b %d, %Y") if dt else str(raw or "-") + + def n(key: str) -> int: + raw = usage.get(key, 0) + return raw if isinstance(raw, int) and not isinstance(raw, bool) else 0 + + def stat(value: int, label: str, color: str = theme.TEXT) -> Text: + out = Text(f"{value:,}", style=f"bold {color}") + out.append(f"\n{label}", style=theme.LABEL) + return out + + start, end = parsed(window.get("start")), parsed(window.get("end")) + now = datetime.now(timezone.utc) + if start and end and end > start: + elapsed = max(0.0, min(1.0, (now - start).total_seconds() / (end - start).total_seconds())) + else: + elapsed = 0.0 + track_cells = 12 + filled = round(elapsed * track_cells) + + window_line = Text() + window_line.append("CURRENT WINDOW ", style=f"bold {theme.TEXT_DIM}") + window_line.append(day(window.get("start")), style=theme.TEXT) + window_line.append(" → ", style=theme.FAINT) + window_line.append(day(window.get("end")), style=theme.TEXT) + window_line.append(" ") + window_line.append("━" * filled, style=theme.ACCENT) + window_line.append("━" * (track_cells - filled), style=theme.BAR_EMPTY) + window_line.append(f" {round(elapsed * 100)}%", style=theme.TEXT_DIM) + + hero = Table(box=None, show_header=False, pad_edge=False, expand=False, padding=(0, 5, 0, 0)) + hero.add_column(min_width=17) + hero.add_column(min_width=11) + hero.add_column(min_width=10) + hero.add_column(min_width=12) + hero.add_row( + stat(n("events_ingested"), "events ingested", theme.ACCENT), + stat(n("sessions"), "sessions"), + stat(n("agents"), "agents"), + stat(n("environments"), "environments"), + ) + + def completion(name: str, finished: int, total: int, detail: str, color: str) -> tuple: + rate = min(100, round((finished / total) * 100)) if total else 0 + cells = 12 + done = round(rate / 100 * cells) + bar = Text("●" * done, style=color) + bar.append("○" * (cells - done), style=theme.BAR_EMPTY) + pct = Text(f"{rate}%", style=f"bold {color}") + counts = Text(f"{finished:,} / {total:,} complete", style=theme.TEXT_DIM) + counts.append(f"\n{detail}", style=theme.FAINT) + return Text(name, style=f"bold {theme.TEXT}"), bar, pct, counts + + pipelines = Table(box=None, show_header=False, pad_edge=False, expand=False, padding=(0, 3, 0, 0)) + pipelines.add_column(min_width=13) + pipelines.add_column(no_wrap=True) + pipelines.add_column(justify="right", no_wrap=True) + pipelines.add_column(min_width=28) + pipelines.add_row(*completion( + "Evaluations", n("evaluation_finishes"), n("evaluation_runs"), + f'{n("evaluations"):,} scores · {n("metrics"):,} metrics', theme.ACCENT, + )) + pipelines.add_row(*completion( + "Audits", n("audit_finishes"), n("audit_runs"), + f'{n("issues_created"):,} issues · {n("alerts_created"):,} alerts', theme.SUCCESS, + )) + + footprint = Table(box=None, show_header=False, pad_edge=False, expand=False, padding=(0, 8, 0, 0)) + footprint.add_column(min_width=28) + footprint.add_column(min_width=28) + footprint.add_row( + stat(n("queries_created"), "saved queries", theme.BLUE), + stat(n("dashboards_created"), "dashboards", theme.BLUE), + ) + footprint.add_row( + stat(n("users_active"), f'{n("users_created"):,} members added', theme.SUCCESS), + stat(n("keys_active"), f'{n("keys_created"):,} keys created', theme.SUCCESS), + ) + + updated = Text("Updated ", style=theme.FAINT) + updated.append(_anchor_compact(data.get("calculated_at")), style=theme.TEXT_DIM) + updated.append(" · read-only usage, no limits applied", style=theme.FAINT) + title = Text("usage", style=f"bold {theme.ACCENT}") + title.append(" · organization overview", style=theme.FAINT) + panel = Panel(Group( + window_line, + Text(""), + hero, + Rule(style=theme.THIN_RULE), + Text("PIPELINE COMPLETION", style=f"bold {theme.TEXT_DIM}"), + pipelines, + Rule(style=theme.THIN_RULE), + Text("WORKSPACE & ACCESS", style=f"bold {theme.TEXT_DIM}"), + footprint, + Text(""), + updated, + ), box=ROUNDED, + border_style=theme.ACCENT, title=title, title_align="left", + padding=(0, 1), expand=False) + _stdout.print() + _stdout.print(Padding(panel, (0, 0, 0, 2))) + + +# ── settings (list box + schema box + set card) ────────────────────────────── + +# Setting kinds (a small fixed registry enum) → a human label for the `type` column. +_SETTING_KIND_LABELS = { + "positive_int": "integer", "url": "url", "secret": "secret", + "email_list": "emails", "email_allowlist": "email allowlist", + "channel_set": "channels", "permission_set_ref": "permission set", +} +# Keys whose change is security-sensitive → a stronger confirm warning. +SENSITIVE_SETTINGS = {"allowed_sign_ins", "alerts.webhook_signing_secret"} + + +def _setting_kind(row_or_schema: Any) -> str: + """The kind string from a SettingRow (its ``schema.kind``) or a schema dict (``kind``).""" + sch = getattr(row_or_schema, "schema", None) + if isinstance(sch, dict): + return str(sch.get("kind", "")) + if isinstance(row_or_schema, dict): + return str(row_or_schema.get("kind", "")) + return "" + + +def _humanize_setting_kind(kind: str) -> str: + return _SETTING_KIND_LABELS.get(kind, (kind or "").replace("_", " ") or "—") + + +def _setting_value_text(value: Any, kind: str, *, full: bool = False) -> Text: + """A setting value → styled Text, type-aware: secret → ``(secret)`` (never echoed); lists → + comma-joined; numbers → PINK; empty → ``(unset)``; else TEXT_DIM. ``full`` keeps the whole + value (the set card/confirm); the list passes the default + truncates separately.""" + if kind == "secret": + return Text("(secret)", style=f"italic {theme.FAINT}") + if value is None or value == "": + return Text("(unset)", style=theme.FAINT) + if isinstance(value, list): + if not value: + return Text("(none)", style=theme.FAINT) + return Text(", ".join(str(v) for v in value), style=theme.TEXT_DIM) + if isinstance(value, bool): + return Text("true" if value else "false", style=theme.AMBER) + if isinstance(value, (int, float)): + return Text(str(value), style=theme.PINK) + if isinstance(value, dict): + return Text(_json.dumps(value, ensure_ascii=False), style=theme.TEXT_DIM) + return Text(str(value), style=theme.TEXT_DIM) + + +def render_settings(rows: Sequence[Any], *, current_email: Optional[str] = None) -> None: + """The ``settings list`` view (stdout): an ACCENT panel titled ``settings · {n}`` with columns + ``key · value · type · updated``. The ``value`` is rendered type-aware (lists comma-joined, + numbers pink, secrets masked as ``(secret)``, empty ``(unset)``) and truncated to one line on a + width budget; ``type`` is the humanized kind; ``updated`` is the compact ``updated_at``. Full + values + ``updated_by``/``scope`` live in ``--json``. ``key`` is the handle ``settings set`` takes.""" + items = sorted(rows, key=lambda s: s.key or "") + parsed = [_parse_iso(s.updated_at) for s in items] + multi_year = len({p.year for p in parsed if p is not None}) > 1 + + keys = [s.key or "-" for s in items] + types = [_humanize_setting_kind(_setting_kind(s)) for s in items] + updated = [_fmt_user_joined(s.updated_at, multi_year) for s in items] + val_cells = [_setting_value_text(s.value, _setting_kind(s)) for s in items] + + budget: Optional[int] = None + if items: + def _w(label, vals): + return max(len(label), max((len(v) for v in vals), default=0)) + fixed = _w("key", keys) + _w("type", types) + _w("updated", updated) + budget = max(SCORES_MIN_WIDTH, _stdout.width - fixed - 14) + + rows_out = [] + for i, s in enumerate(items): + v = val_cells[i] + if budget is not None and len(v.plain) > budget: + v = Text(_truncate(v.plain, budget), style=v.style) + rows_out.append([ + Text(keys[i], style=theme.TEXT), + v, + Text(types[i], style=theme.TEXT_DIM), + Text(updated[i], style=theme.TEXT_DIM), + ]) + + title = Text() + title.append("settings", style=f"bold {theme.ACCENT}") + title.append(" · ", style=theme.FAINT) + title.append(str(len(items)), style="bold white") # the count glows — the headline of the list + render_list_panel("settings", header=["key", "value", "type", "updated"], rows=rows_out, + days=set(), order=None, empty_message="no settings", title=title) + + +def _setting_accepts(schema: dict) -> str: + """A concise 'what this setting accepts' summary from its schema, for the schema table.""" + kind = str(schema.get("kind", "")) + if kind == "positive_int": + lo, hi, unit = schema.get("min"), schema.get("max"), schema.get("unit") + if lo is not None and hi is not None: + return f"{lo}–{hi}" + (f" {unit}" if unit else "") + return "integer" + if kind == "channel_set": + return " · ".join(str(o) for o in (schema.get("options") or [])) or "channels" + if kind == "email_allowlist": + # The allowlist FILTERS its org's members rather than granting access, + # so the empty case is the one people get wrong — say it here, since + # this column is all `settings schema` shows about accepted values. + return "emails / *@domain · empty = no restriction" + if kind == "email_list": + return "emails / *@domain" + if kind == "url": + return "a url" + if kind == "secret": + return "a secret" + if kind == "permission_set_ref": + return "a permission-set name" + return "—" + + +def render_settings_schema(entries: Sequence[dict]) -> None: + """The ``settings schema`` view (stdout): an ACCENT panel titled ``settings schema · {n}`` with + columns ``key · type · accepts · description``. ``accepts`` summarizes each kind's constraints + (int range+unit, channel options, …); ``description`` (wraps) explains the setting.""" + items = sorted(entries, key=lambda e: e.get("key", "")) + rows = [] + for e in items: + rows.append([ + Text(str(e.get("key", "")), style=theme.TEXT), + Text(_humanize_setting_kind(str(e.get("kind", ""))), style=theme.TEXT_DIM), + Text(_setting_accepts(e), style=theme.LABEL), + Text(str(e.get("description", "")), style=theme.TEXT_DIM), + ]) + title = Text() + title.append("settings schema", style=f"bold {theme.ACCENT}") + title.append(f" · {len(items)}", style=theme.LABEL) + render_list_panel("settings schema", header=["key", "type", "accepts", "description"], + rows=rows, days=set(), order=None, empty_message="no settings", + last_col="wrap", title=title) + + +def confirm_setting_change(key: str, old_value: Any, new_value: Any, kind: str) -> bool: + """Plain set confirm (stderr): ``⚠ set {key}?`` + an ``{old} → {new}`` change line (secrets + show ``set a new secret value`` instead of echoing) + a sensitive-key warning + ``[y/N]``.""" + h = Text() + h.append("⚠ ", style=f"bold {theme.AMBER}") + h.append("set ", style=theme.TEXT) + h.append(key, style=f"bold {theme.ACCENT}") + h.append("?", style=theme.TEXT) + if kind == "secret": + change = Text("set a new secret value", style=theme.LABEL) + else: + change = Text() + change.append_text(_setting_value_text(old_value, kind, full=True)) + change.append(" → ", style=theme.FAINT) + change.append_text(_setting_value_text(new_value, kind, full=True)) + parts = [h, change] + if key in SENSITIVE_SETTINGS: + warn = Text() + warn.append("⚠ ", style=theme.AMBER) + warn.append("this is a security-sensitive setting", style=theme.AMBER) + parts.append(warn) + # Clearing the sign-in allowlist LOOKS like a deletion and is in fact a + # widening: the list restricts which members may sign in, so an empty one + # restricts nobody. Spell that out — `→ (none)` reads as the opposite. + if key == "allowed_sign_ins" and isinstance(new_value, list) and not new_value: + widen = Text() + widen.append("⚠ ", style=theme.AMBER) + widen.append( + "an empty list is NOT a lockout — it removes the restriction, " + "letting every member of this org sign in", + style=theme.AMBER, + ) + parts.append(widen) + _stderr.print() + for p in parts: + _stderr.print(Text(" ") + p) + return typer.confirm(_ansi(" confirm?", dim=True), default=False, err=True, prompt_suffix=" ") + + +def render_setting_updated(row: Any, kind: str) -> None: + """The ``settings set`` success view (stdout): a GREEN ``setting updated`` card — key hero, the + new value (type-aware; secrets masked), ``updated by you · just now``.""" + line1 = Text(row.key or "-", style=f"bold {theme.TEXT}") + line2 = _setting_value_text(row.value, kind, full=True) + line3 = Text() + line3.append("updated by ", style=theme.LABEL) + line3.append("you", style=theme.TEXT) + line3.append(" · ", style=theme.FAINT) + line3.append("just now", style=theme.LABEL) + card = Panel(Group(line1, line2, line3), box=ROUNDED, border_style=theme.SUCCESS, + title=Text("setting updated", style=f"bold {theme.SUCCESS}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print() + _stdout.print(Padding(card, (0, 0, 0, 2))) + _stdout.print() + + +def setting_not_found(key: str) -> None: + """Red ``error`` notice box (stderr): ``✗ no setting named "<key>"`` + a dim ``settings list`` + hint. Consistent with the other command not-found boxes (keys/users/query/alerts).""" + body = Text() + body.append("✗ ", style=f"bold {theme.ERROR}") + body.append('no setting named "', style=theme.TEXT) + body.append(key, style=f"bold {theme.ACCENT}") + body.append('"', style=theme.TEXT) + hint = Text() + hint.append("run ", style=theme.FAINT) + hint.append("fp settings list", style=theme.ACCENT) + hint.append(" to see settings", style=theme.FAINT) + _notice_box(Group(body, hint), color=theme.ERROR, title="error") + + +def setting_no_change(key: str, value: Any, kind: str) -> None: + """Faint ``no change`` notice box (stderr): ``○ {key} is already {value}``.""" + if _quiet: + return + body = Text() + body.append("○ ", style=theme.FAINT) + body.append(key, style=f"bold {theme.ACCENT}") + body.append(" is already ", style=theme.LABEL) + body.append_text(_setting_value_text(value, kind, full=True)) + _notice_box(body, color=theme.FAINT, title="no change") + + +def setting_failed(message: str) -> None: + """Red ``error`` notice box (stderr): ``✗ {message}`` — the server's clean validation message + (no raw HTTP).""" + body = Text() + body.append("✗ ", style=f"bold {theme.ERROR}") + body.append(message or "the setting could not be updated", style=theme.TEXT) + _notice_box(body, color=theme.ERROR, title="error") + + +# ══ incidents renderers (added) ══ +# The incident triage surface lives under `alerts`, so it follows the alerts/query family's +# convention: BOXES carry data (the list/count/show cards + the green created cards + the amber +# delete preview), while ACTION FEEDBACK (✓ / ○ / ✗ / ⚠ confirm) is plain indented stderr lines. +# `state` is a small fixed enum — firing / acknowledged / resolved — value-mapped to a colour. + +# Incident state → (marker, colour). firing = breaching (red ●), acknowledged = being handled +# (amber ●), resolved = closed (faint ○). Unknown states fall back to a neutral dim dot. +_INCIDENT_STATE = { + "firing": ("●", theme.ERROR), + "acknowledged": ("●", theme.AMBER), + "resolved": ("○", theme.FAINT), +} + +# Activity-log kind → colour (open enum; unknown kinds render neutral). Mirrors the state hues: +# opened = breach red, acknowledged = amber, resolved = green; anything else neutral TEXT. +_ACTIVITY_KIND_COLORS = { + "opened": theme.ERROR, "acknowledged": theme.AMBER, "resolved": theme.SUCCESS, +} + + +def _incident_status_cell(state: str, *, muted: bool = False) -> Text: + """Incident state as a colour-coded dot + word — same dot vocabulary as keys/users/alerts. + firing red ● / acknowledged amber ● / resolved faint ○; unknown → neutral dim. ``muted`` dims + the whole cell. Resolved uses a HOLLOW ○ so it stays distinguishable under NO_COLOR (firing + vs acknowledged differ by their word).""" + marker, color = _INCIDENT_STATE.get((state or "").lower(), ("●", theme.TEXT_DIM)) + if muted: + color = theme.FAINT + t = Text() + t.append(marker + " ", style=color) + t.append(state or "-", style=color) + return t + + +def _assignees_cell(assignees: Optional[Sequence[str]]) -> Text: + """The list ``assignees`` column: up to two emails comma-joined, then ``+N`` for the rest; + ``—`` (faint) when nobody is assigned. Keeps the row one line however many are assigned.""" + names = [str(a) for a in (assignees or []) if a] + if not names: + return Text("—", style=theme.FAINT) + shown = names[:2] + t = Text(", ".join(shown), style=theme.TEXT_DIM) + extra = len(names) - len(shown) + if extra: + t.append(f" +{extra}", style=theme.LABEL) + return t + + +def _incident_source_cell(source: Optional[str], alert_name: Optional[str]) -> Text: + """The list/show ``source`` column: where the issue came from — ``manual``, ``alert``, or + ``audit``. When there's a parent alert its name trails the label (demoted from its old spot as + the primary column, since only a minority of issues have one). ``—`` when the server sent no + source at all.""" + src = (source or "").strip().lower() + if not src: + src = "alert" if alert_name else "" + if not src: + return Text("—", style=theme.FAINT) + t = Text(src, style=theme.TEXT_DIM) + if alert_name: + t.append(f" {alert_name}", style=theme.LABEL) + return t + + +def render_incidents(incidents: Sequence[Any], *, show_id: bool = False) -> None: + """The ``incidents list`` view (stdout): an ACCENT panel titled ``incidents · {n}`` with columns + ``id · title · source · severity · state · opened · assignees``. The id IS the handle for the + action commands, so it's always shown — short (``1f58…9826``) by default, full with + ``--show-id``. ``title`` is the primary identifying column: every issue has one, whereas only + alert-linked issues carry an ``alert_name``, so titling by alert left the manual and + audit-born rows mutually indistinguishable. ``source`` says where it came from and carries the + alert name when there is one. Severity is colour-coded; state via the + firing/acknowledged/resolved dot map; ``opened`` is the compact age of ``opened_at``. Server + order (newest-opened first) is preserved. Full ids / raw timestamps live only in ``--json``.""" + items = list(incidents) + header = ["id", "title", "source", "severity", "state", "opened", "assignees"] + rows = [] + for i in items: + iid = (getattr(i, "id", "") or "-") if show_id else _short_id(getattr(i, "id", "") or "-") + opened = _age_compact(getattr(i, "opened_at", "")) or "-" + label = getattr(i, "title", None) or getattr(i, "alert_name", None) or "—" + rows.append([ + Text(iid, style=theme.TEXT_DIM), + Text(label, style=theme.TEXT), + _incident_source_cell(getattr(i, "source", None), getattr(i, "alert_name", None)), + _severity_cell(getattr(i, "alert_severity", "") or "-"), + _incident_status_cell(getattr(i, "state", "")), + Text(opened, style=theme.TEXT_DIM), + _assignees_cell(getattr(i, "assignees", None)), + ]) + title = Text() + title.append("issues", style=f"bold {theme.ACCENT}") + title.append(f" · {len(items)}", style=theme.LABEL) + render_list_panel("issues", header=header, rows=rows, days=set(), order=None, + empty_message="no issues", title=title) + + +def incidents_footer(incidents: Sequence[Any]) -> None: + """Distribution summary under the incidents box (stderr): ``{total} incidents · firing {f} · + acknowledged {a} · resolved {r}`` — each state count in its state colour, present only when + that state actually appears.""" + if _quiet: + return + total = len(incidents) + counts: dict = {} + for i in incidents: + st = (getattr(i, "state", "") or "").lower() + counts[st] = counts.get(st, 0) + 1 + line = Text(" ") + line.append(f"{total} issue{'' if total == 1 else 's'}", style=theme.LABEL) + for st in ("firing", "acknowledged", "resolved"): + n = counts.get(st, 0) + if n: + line.append(" · ", style=theme.FAINT) + line.append(f"{n} {st}", style=_INCIDENT_STATE[st][1]) + _stderr.print(line) + _stderr.print() + + +def render_incident_count(count: int, *, state: Optional[str] = None) -> None: + """The ``incidents count`` view (stdout): a compact ACCENT ``incidents`` card — the count as a + pink hero number + a ``{state} incidents`` / ``open incidents`` qualifier (the server's default + counts firing + acknowledged, i.e. the open ones).""" + body = Text() + body.append(str(count), style=f"bold {theme.PINK}") + body.append(" ") + body.append(f"{state} issues" if state else "open issues", style=theme.LABEL) + panel = Panel(body, box=ROUNDED, border_style=theme.ACCENT, + title=Text("issues", style=f"bold {theme.ACCENT}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print() + _stdout.print(Padding(panel, (0, 0, 0, 2))) + + +def _section_title(word: str, n: int) -> Text: + """A ``{word} · {n}`` card title (bold-ACCENT word + LABEL count) for the show sub-sections.""" + t = Text() + t.append(word, style=f"bold {theme.ACCENT}") + t.append(f" · {n}", style=theme.LABEL) + return t + + +def _incident_comments_body(comments: Sequence[dict]): + """A borderless ``author · when · body`` table for the show ``comments`` section. A soft-deleted + comment (``deleted_at`` set, or ``body`` null) shows a dim italic ``(deleted)`` tombstone; the + body folds so a long comment wraps within the card.""" + table = Table(box=None, pad_edge=False, show_header=False) + table.add_column(style=theme.TEXT_DIM, no_wrap=True) # author + table.add_column(style=theme.FAINT, no_wrap=True) # when + table.add_column(overflow="fold") # body + for c in comments: + author = str(c.get("author_email") or "—") + when = _relative_age(c.get("created_at")) or "-" + if c.get("deleted_at") or c.get("body") is None: + body = Text("(deleted)", style=f"italic {theme.FAINT}") + else: + body = Text(str(c.get("body")), style=theme.TEXT) + table.add_row(author, when, body) + return table + + +def _incident_subscribers_body(subscribers: Sequence[dict]): + """A borderless ``email · source · when`` table for the show ``subscribers`` section.""" + table = Table(box=None, pad_edge=False, show_header=False) + table.add_column(style=theme.TEXT, no_wrap=True) # email + table.add_column(style=theme.LABEL, no_wrap=True) # source + table.add_column(style=theme.FAINT, no_wrap=True) # when + for s in subscribers: + email = str(s.get("email") or "—") + source = str(s.get("source") or "") + when = _relative_age(s.get("subscribed_at")) or "-" + table.add_row(email, source, when) + return table + + +def _incident_activity_body(activity: Sequence[dict]): + """A borderless ``kind · actor · when`` table for the show ``activity`` log — kind coloured by + the kind map (opened red / acknowledged amber / resolved green / else neutral), humanized + (``_`` → space); actor is the email or ``system``.""" + table = Table(box=None, pad_edge=False, show_header=False) + table.add_column(no_wrap=True) # kind (coloured) + table.add_column(style=theme.TEXT_DIM, no_wrap=True) # actor + table.add_column(style=theme.FAINT, no_wrap=True) # when + for a in activity: + kind = str(a.get("kind") or "") + color = _ACTIVITY_KIND_COLORS.get(kind, theme.TEXT) + table.add_row(Text(kind.replace("_", " ") or "-", style=color), + str(a.get("actor") or "—"), _relative_age(a.get("at")) or "-") + return table + + +def render_incident_show(incident: Any) -> None: + """The ``incidents show <id>`` view (stdout): a stack of ACCENT cards — an identity card (the + issue's own title + short id, then ``severity · state · source · opened {age}``, an + ``acknowledged by`` / ``assigned to`` line, and a breach line) followed by ``comments`` / + ``subscribers`` / ``activity`` sections (each omitted when empty) — then a dim ``--json`` + pointer. The header used to be hardcoded to the literal ``manual incident`` whenever there was + no parent alert, which mislabelled every audit-born issue and told the reader nothing; it now + shows the real title and the real source. Presentation only.""" + title = Text() + heading = ( + getattr(incident, "title", None) + or getattr(incident, "alert_name", None) + or "untitled issue" + ) + title.append(heading, style=f"bold {theme.ACCENT}") + title.append(f" · {_short_id(getattr(incident, 'id', '') or '-')}", style=theme.LABEL) + + l1 = Text() + l1.append_text(_severity_cell(getattr(incident, "alert_severity", "") or "-")) + l1.append(" · ", style=theme.FAINT) + l1.append_text(_incident_status_cell(getattr(incident, "state", ""))) + src = _incident_source_cell( + getattr(incident, "source", None), getattr(incident, "alert_name", None) + ) + if src.plain != "—": + l1.append(" · ", style=theme.FAINT) + l1.append_text(src) + age = _relative_age(getattr(incident, "opened_at", "")) + if age: + l1.append(" · ", style=theme.FAINT) + l1.append("opened ", style=theme.LABEL) + l1.append(age, style=theme.TEXT) + lines: List[Text] = [l1] + + who = Text() + has_who = False + if getattr(incident, "acknowledged_by", None): + who.append("acknowledged by ", style=theme.LABEL) + who.append(str(incident.acknowledged_by), style=theme.TEXT) + has_who = True + if getattr(incident, "assignees", None): + if has_who: + who.append(" · ", style=theme.FAINT) + who.append("assigned to ", style=theme.LABEL) + who.append(", ".join(incident.assignees), style=theme.TEXT) + has_who = True + if has_who: + lines.append(who) + + if getattr(incident, "breach_summary", None): + b = Text() + b.append("breach ", style=theme.LABEL) + b.append(str(incident.breach_summary), style=theme.TEXT_DIM) + lines.append(b) + elif getattr(incident, "breach_value", None) is not None: + b = Text() + b.append("breach value ", style=theme.LABEL) + b.append(_fmt_alert_num(incident.breach_value), style=theme.PINK) + lines.append(b) + + _alert_card(title, Group(*lines)) + + comments = list(getattr(incident, "comments", None) or []) + if comments: + _alert_card(_section_title("comments", len(comments)), _incident_comments_body(comments)) + subscribers = list(getattr(incident, "subscribers", None) or []) + if subscribers: + _alert_card(_section_title("subscribers", len(subscribers)), _incident_subscribers_body(subscribers)) + activity = list(getattr(incident, "activity", None) or []) + if activity: + _alert_card(_section_title("activity", len(activity)), _incident_activity_body(activity)) + + if not _quiet: + foot = Text(" ") + foot.append("view raw with ", style=theme.FAINT) + foot.append("--json", style=theme.ACCENT) + _stderr.print() + _stderr.print(foot) + _stderr.print() + + +def render_incident_opened(*, summary: str, severity: str, state: str, title: str = "") -> None: + """The ``issues open`` success view (stdout): a GREEN ``issue opened`` card — the title + (falling back to the summary) as the hero line, ``{severity} · {state}``, and + ``opened by you · just now``. When both are present the summary renders beneath the title.""" + line1 = Text(title or summary or "issue", style=f"bold {theme.TEXT}") + if title and summary and summary != title: + line1.append("\n") + line1.append(summary, style=theme.LABEL) + line2 = Text() + line2.append_text(_severity_cell(severity or "-")) + line2.append(" · ", style=theme.FAINT) + line2.append_text(_incident_status_cell(state)) + line3 = Text() + line3.append("opened by ", style=theme.LABEL) + line3.append("you", style=theme.TEXT) + line3.append(" · ", style=theme.FAINT) + line3.append("just now", style=theme.LABEL) + card = Panel(Group(line1, line2, line3), box=ROUNDED, border_style=theme.SUCCESS, + title=Text("issue opened", style=f"bold {theme.SUCCESS}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print() + _stdout.print(Padding(card, (0, 0, 0, 2))) + + +def render_incident_comment_added(comment: Any) -> None: + """The ``incidents comment-add`` success view (stdout): a GREEN ``comment added`` card — a + ``by {author} · just now`` line then the comment body (folds for long text).""" + line1 = Text() + line1.append("by ", style=theme.LABEL) + line1.append(getattr(comment, "author_email", None) or "you", style=theme.TEXT) + line1.append(" · ", style=theme.FAINT) + line1.append("just now", style=theme.LABEL) + body = Text(getattr(comment, "body", None) or "", style=theme.TEXT) + card = Panel(Group(line1, Text(), body), box=ROUNDED, border_style=theme.SUCCESS, + title=Text("comment added", style=f"bold {theme.SUCCESS}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print() + _stdout.print(Padding(card, (0, 0, 0, 2))) + + +def render_incident_comment_delete_preview(comment: Any) -> None: + """The ``incidents comment-delete`` preview (stderr): an AMBER ``delete comment`` box — a + ``by {author} · {age}`` line + the comment body, so the operator sees what's about to go.""" + line1 = Text() + line1.append("by ", style=theme.LABEL) + line1.append(getattr(comment, "author_email", None) or "—", style=f"bold {theme.ACCENT}") + when = _relative_age(getattr(comment, "created_at", "")) + if when: + line1.append(" · ", style=theme.FAINT) + line1.append(when, style=theme.LABEL) + body = Text(getattr(comment, "body", None) or "(deleted)", style=theme.TEXT_DIM) + card = Panel(Group(line1, body), box=ROUNDED, border_style=theme.AMBER, + title=Text("delete comment", style=f"bold {theme.AMBER}"), + title_align="left", padding=(0, 1), expand=False) + _stderr.print() + _stderr.print(Padding(card, (0, 0, 0, 2))) + + +def confirm_incident_resolve(incident_id: str, alert_name: Optional[str] = None) -> bool: + """Plain resolve confirm (stderr): ``⚠ resolve issue {short id} ({alert})?`` + ``this closes + it`` + ``confirm? [y/N]`` (calm — an operator can re-open later). Returns the answer.""" + h = Text() + h.append("⚠ ", style=f"bold {theme.AMBER}") + h.append("resolve issue ", style=theme.TEXT) + h.append(_short_id(incident_id or "-"), style=f"bold {theme.ACCENT}") + if alert_name: + h.append(f" ({alert_name})", style=theme.LABEL) + h.append("?", style=theme.TEXT) + return confirm_line(h, Text("this closes it", style=theme.LABEL)) + + +def confirm_incident_comment_delete() -> bool: + """Plain comment-delete confirm (stderr): ``⚠ this permanently removes the comment — it can't be + undone`` + ``confirm? [y/N]`` (the amber preview box was printed just above). Returns y/N.""" + h = Text() + h.append("⚠ ", style=f"bold {theme.AMBER}") + h.append("this permanently removes the comment — it can't be undone", style=theme.LABEL) + return confirm_line(h) + + +def incident_acked(incident_id: str) -> None: + """Plain green line (stderr): ``✓ acknowledged issue {short id}``.""" + if _quiet: + return + _stderr.print() + _plain(("✓ ", theme.SUCCESS), ("acknowledged issue ", theme.TEXT), + (_short_id(incident_id or "-"), theme.ACCENT)) + + +def incident_resolved(incident_id: str) -> None: + """Plain green line (stderr): ``✓ resolved issue {short id}``.""" + if _quiet: + return + _stderr.print() + _plain(("✓ ", theme.SUCCESS), ("resolved issue ", theme.TEXT), + (_short_id(incident_id or "-"), theme.ACCENT)) + + +def incident_assigned(incident_id: str, assignees: Sequence[str]) -> None: + """Plain green line (stderr): ``✓ assigned {short id} · a@x, b@x`` — or ``✓ cleared assignees + on {short id}`` when the list is empty.""" + if _quiet: + return + _stderr.print() + short = _short_id(incident_id or "-") + names = [str(a) for a in (assignees or []) if a] + if names: + _plain(("✓ ", theme.SUCCESS), ("assigned ", theme.TEXT), (short, theme.ACCENT), + (" · ", theme.FAINT), (", ".join(names), theme.TEXT_DIM)) + else: + _plain(("✓ ", theme.SUCCESS), ("cleared assignees on ", theme.TEXT), (short, theme.ACCENT)) + + +def incident_subscribed(incident_id: str, email: Optional[str] = None) -> None: + """Plain green line (stderr): ``✓ subscribed {who} to issue {short id}`` (``who`` = the email + or ``you``).""" + if _quiet: + return + _stderr.print() + _plain(("✓ ", theme.SUCCESS), ("subscribed ", theme.TEXT), (email or "you", theme.ACCENT), + (" to issue ", theme.TEXT), (_short_id(incident_id or "-"), theme.TEXT_DIM)) + + +def incident_unsubscribed(incident_id: str, email: Optional[str] = None) -> None: + """Plain green line (stderr): ``✓ unsubscribed {who} from issue {short id}``.""" + if _quiet: + return + _stderr.print() + _plain(("✓ ", theme.SUCCESS), ("unsubscribed ", theme.TEXT), (email or "you", theme.ACCENT), + (" from issue ", theme.TEXT), (_short_id(incident_id or "-"), theme.TEXT_DIM)) + + +def incident_comment_deleted() -> None: + """Plain green line (stderr): ``✓ deleted comment``.""" + if _quiet: + return + _stderr.print() + _plain(("✓ ", theme.SUCCESS), ("deleted comment", theme.TEXT)) + + +def incident_not_found(incident_id: str) -> None: + """Plain red line (stderr): ``✗ no issue {short id}`` + a dim hint to ``issues list``. + Always shown (errors ignore ``--quiet``).""" + _stderr.print() + _plain(("✗ ", f"bold {theme.ERROR}"), ("no issue ", theme.TEXT), + (_short_id(incident_id or "-"), f"bold {theme.ACCENT}")) + _plain(("run ", theme.FAINT), ("fp issues list", theme.ACCENT), + (" to see issues", theme.FAINT)) + + +def incident_comment_not_found(comment_id: str) -> None: + """Plain red line (stderr): ``✗ no comment {short id} on this issue``.""" + _stderr.print() + _plain(("✗ ", f"bold {theme.ERROR}"), ("no comment ", theme.TEXT), + (_short_id(comment_id or "-"), f"bold {theme.ACCENT}"), (" on this issue", theme.TEXT)) + + +def incident_failed(message: str) -> None: + """Plain red line (stderr): ``✗ {message}`` — the server's clean message (never raw HTTP).""" + _stderr.print() + _plain(("✗ ", f"bold {theme.ERROR}"), (message, theme.TEXT)) + + +def render_incident_comments(comments: Sequence[Any]) -> None: + """The ``incidents comment-list`` view (stdout): a boxed ``comments · {n}`` table with columns + ``author · when · body`` — the body folds (so a long comment wraps), a soft-deleted comment + shows a dim italic ``(deleted)`` tombstone. Takes ``IncidentComment`` dataclasses.""" + rows = [] + for c in comments: + when = _relative_age(getattr(c, "created_at", "")) or "-" + if getattr(c, "deleted_at", None) or getattr(c, "body", None) is None: + body = Text("(deleted)", style=f"italic {theme.FAINT}") + else: + body = Text(str(getattr(c, "body", "")), style=theme.TEXT) + rows.append([ + Text(getattr(c, "author_email", "") or "—", style=theme.TEXT_DIM), + Text(when, style=theme.FAINT), + body, + ]) + render_list_panel("comments", header=["author", "when", "body"], rows=rows, days=set(), + order=None, empty_message="no comments yet", last_col="wrap", + title=_section_title("comments", len(list(comments)))) + + +def render_incident_subscribers(subscribers: Sequence[Any]) -> None: + """The ``incidents subscribers`` view (stdout): a boxed ``subscribers · {n}`` table with columns + ``email · source · subscribed`` (the relative subscribe time). Takes ``IncidentSubscriber`` + dataclasses.""" + rows = [] + for s in subscribers: + when = _relative_age(getattr(s, "subscribed_at", "")) or "-" + rows.append([ + Text(getattr(s, "email", "") or "—", style=theme.TEXT), + Text(getattr(s, "source", "") or "", style=theme.LABEL), + Text(when, style=theme.FAINT), + ]) + render_list_panel("subscribers", header=["email", "source", "subscribed"], rows=rows, + days=set(), order=None, empty_message="no subscribers", + title=_section_title("subscribers", len(list(subscribers)))) + + +# ══ end incidents renderers ══ + + +# ══ agent renderers (added) ══ +# The `fp agent` group, on the shared design language: BOXES carry data (health card, +# models/chats lists, the show transcript), PLAIN indented lines carry action feedback +# (rename/delete cards + ✓/○/✗/⚠ lines). Reuses render_list_panel / _age_compact / _short_id / +# confirm_line / cancelled_plain / _plain. The `ask` ANSWER is printed to stdout by the command +# (the pipeable payload) — only its surrounding chrome lives here. + + +def _msg_text(content: Any) -> str: + """Extract the display text from a stored message ``content`` (str or ``{"text": ...}``) — + mirrors the dashboard's ``textOf`` so the CLI transcript reads identically.""" + if isinstance(content, str): + return content + if isinstance(content, dict) and isinstance(content.get("text"), str): + return content["text"] + return "" + + +def render_agent_health(*, configured: bool, default_model: Optional[str], model_count: int) -> None: + """The ``agent health`` view (stdout): a compact ACCENT ``assistant`` card — ``● configured`` + (green) / ``○ not configured`` (dim) from ``enabled``/``llm_configured``, then ``default model + {m}`` and ``{n} models available`` when reported. ``--json`` emits the raw payload.""" + line1 = Text() + if configured: + line1.append("● ", style=theme.SUCCESS) + line1.append("configured", style=theme.SUCCESS) + else: + line1.append("○ ", style=theme.FAINT) + line1.append("not configured", style=theme.TEXT_DIM) + rows: List[Any] = [line1] + if default_model: + l = Text() + l.append("default model ", style=theme.LABEL) + l.append(default_model, style=theme.TEXT) + rows.append(l) + if model_count: + l = Text() + l.append(f"{model_count} model{'' if model_count == 1 else 's'}", style=theme.LABEL) + l.append(" available", style=theme.FAINT) + rows.append(l) + panel = Panel(Group(*rows), box=ROUNDED, border_style=theme.ACCENT, + title=Text("assistant", style=f"bold {theme.ACCENT}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print() + _stdout.print(Padding(panel, (0, 0, 0, 2))) + _stdout.print() + + +def render_agent_models(models: Sequence[str], *, default_model: Optional[str] = None) -> None: + """The ``agent models`` view (stdout): an ACCENT ``models · {n}`` box, one model per row with the + default marked (``●`` ACCENT + a dim ``default`` tag; ``·`` FAINT otherwise). Empty → a calm + ``no models reported`` (the assistant may be unconfigured). ``--json`` emits ``{models, + default_model}``.""" + n = len(models) + if n == 0: + body: Any = Text("no models reported", style=theme.TEXT_DIM) + else: + table = Table(box=None, pad_edge=False, show_header=False, padding=(0, 2, 0, 0)) + table.add_column(no_wrap=True) # marker + table.add_column(no_wrap=True) # name + table.add_column(no_wrap=True) # default tag + for m in models: + is_default = (default_model is not None and m == default_model) + marker = Text("●", style=theme.ACCENT) if is_default else Text("·", style=theme.FAINT) + name = Text(str(m), style=f"bold {theme.TEXT}" if is_default else theme.TEXT) + tag = Text("default", style=theme.LABEL) if is_default else Text("") + table.add_row(marker, name, tag) + body = table + title = Text() + title.append("models", style=f"bold {theme.ACCENT}") + title.append(f" · {n}", style=theme.LABEL) + panel = Panel(body, box=ROUNDED, border_style=theme.ACCENT, title=title, + title_align="left", padding=(0, 1), expand=False) + _stdout.print() + _stdout.print(Padding(panel, (0, 0, 0, 2))) + _stdout.print() + + +def render_agent_chats(chats: Sequence[dict]) -> None: + """The ``agent chats`` view (stdout): a boxed ``chats · {n}`` table with columns ``chat-id · + title · messages · updated`` (newest first). ``chat-id`` is the SHORT, copy-friendly handle + (the first 8 hex before the UUID's first ``-``) that ``agent show``/``rename``/``delete``/``ask + --chat`` accept as a prefix; the ``title`` absorbs any width squeeze (``…``-truncated on a + budget). The count glows. Empty → ``no chats``.""" + items = sorted(chats, key=lambda c: c.get("updated_at") or "", reverse=True) # newest first + ids = [_short_chat_id(str(c.get("id") or "-")) for c in items] # short copy-friendly handle + titles = [str(c.get("title") or "untitled") for c in items] + mcs = [str(c.get("message_count")) if c.get("message_count") is not None else "-" for c in items] + ages = [_age_compact(c.get("updated_at")) for c in items] + + # Keep chat-id (and messages/updated) at full width; the title is the only flexible column, + # so budget it to the leftover space and truncate it — the chat-id is never cut. + budget: Optional[int] = None + if items: + def _w(label, vals): + return max(len(label), max((len(v) for v in vals), default=0)) + fixed = _w("chat-id", ids) + _w("messages", mcs) + _w("updated", [a or "—" for a in ages]) + budget = max(SCORES_MIN_WIDTH, _stdout.width - fixed - (2 * 4 + 8)) # per-col padding + chrome + + rows = [] + for i, c in enumerate(items): + t = _truncate(titles[i], budget) if budget is not None else titles[i] + updated = Text(ages[i], style=theme.TEXT_DIM) if ages[i] else Text("—", style=theme.FAINT) + rows.append([ + Text(ids[i], style=theme.TEXT_DIM), # the short copy-friendly handle + Text(t, style=theme.TEXT), + Text(mcs[i], style=theme.TEXT_DIM), + updated, + ]) + title = Text() + title.append("chats", style=f"bold {theme.ACCENT}") + title.append(" · ", style=theme.FAINT) + title.append(str(len(items)), style="bold white") # the count glows — the headline of the list + render_list_panel("chats", header=["chat-id", "title", "messages", "updated"], rows=rows, + days=set(), order=None, empty_message="no chats", title=title) + + +def render_agent_show(*, title: Optional[str], messages: Sequence[dict], chat_id: str) -> None: + """The ``agent show <id>`` view (stdout): the conversation as a readable transcript inside an + ACCENT panel titled ``{chat title} · {n} messages · {short id}``. Each message is a turn — a + role label (``you`` ACCENT / ``assistant`` SUCCESS) then its indented text (``_msg_text`` handles + the str-or-``{text}`` content). Empty → ``no messages yet``. ``--json`` emits ``{title, messages}``.""" + msgs = list(messages or []) + width = min(max(_stdout.width - 4, 40), 100) + if not msgs: + body: Any = Text("no messages yet", style=theme.TEXT_DIM) + else: + parts: List[Any] = [] + for i, m in enumerate(msgs): + if i: + parts.append(Text("")) # blank line between turns + role = str(m.get("role", "") or "") + if role == "assistant": + label = Text("assistant", style=f"bold {theme.SUCCESS}") + elif role == "user": + label = Text("you", style=f"bold {theme.ACCENT}") + else: + label = Text(role or "—", style=f"bold {theme.TEXT_DIM}") + parts.append(label) + parts.append(Padding(Text(_msg_text(m.get("content")) or "—", style=theme.TEXT), (0, 0, 0, 2))) + body = Group(*parts) + ttl = Text() + ttl.append(title or "untitled", style=f"bold {theme.ACCENT}") + ttl.append(f" · {len(msgs)} message{'' if len(msgs) == 1 else 's'}", style=theme.LABEL) + ttl.append(f" · {_short_chat_id(str(chat_id))}", style=theme.TEXT_DIM) + panel = Panel(body, box=ROUNDED, border_style=theme.ACCENT, title=ttl, title_align="left", + padding=(0, 1), expand=False, width=width) + _stdout.print() + _stdout.print(Padding(panel, (0, 0, 0, 2))) + _stdout.print() + + +def render_agent_renamed(*, chat_id: str, title: str, old_title: Optional[str] = None) -> None: + """The ``agent rename`` success view (stderr chrome): a GREEN ``chat renamed`` card — the new + title (hero) + dim `` was {old}`` when the title actually changed, then ``renamed · just now · + {short id}``.""" + line1 = Text() + line1.append(title or "untitled", style=f"bold {theme.TEXT}") + if old_title and old_title != title: + line1.append(" was ", style=theme.FAINT) + line1.append(old_title, style=theme.FAINT) + line2 = Text() + line2.append("renamed", style=theme.LABEL) + line2.append(" · ", style=theme.FAINT) + line2.append("just now", style=theme.LABEL) + line2.append(" · ", style=theme.FAINT) + line2.append(_short_chat_id(str(chat_id)), style=theme.TEXT_DIM) + card = Panel(Group(line1, line2), box=ROUNDED, border_style=theme.SUCCESS, + title=Text("chat renamed", style=f"bold {theme.SUCCESS}"), + title_align="left", padding=(0, 1), expand=False) + _stderr.print() + _stderr.print(Padding(card, (0, 0, 0, 2))) + _stderr.print() + + +def render_agent_delete_preview(*, title: Optional[str], message_count: int, chat_id: str) -> None: + """The ``agent delete`` preview (stderr): an AMBER ``delete chat`` box — the chat's title + (ACCENT) + ``{n} messages · {short id}`` — so the operator confirms it's the right chat.""" + line1 = Text(title or "untitled", style=f"bold {theme.ACCENT}") + line2 = Text() + line2.append(str(message_count), style=theme.TEXT_DIM) + line2.append(f" message{'' if message_count == 1 else 's'}", style=theme.LABEL) + line2.append(" · ", style=theme.FAINT) + line2.append(_short_chat_id(str(chat_id)), style=theme.TEXT_DIM) + card = Panel(Group(line1, line2), box=ROUNDED, border_style=theme.AMBER, + title=Text("delete chat", style=f"bold {theme.AMBER}"), + title_align="left", padding=(0, 1), expand=False) + _stderr.print() + _stderr.print(Padding(card, (0, 0, 0, 2))) + + +def confirm_agent_delete() -> bool: + """Plain delete confirm (stderr): ``⚠ this permanently removes the chat — it can't be undone`` + + ``confirm? [y/N]`` (the amber preview box was printed just above). Returns y/N.""" + h = Text() + h.append("⚠ ", style=f"bold {theme.AMBER}") + h.append("this permanently removes the chat — it can't be undone", style=theme.LABEL) + return confirm_line(h) + + +def agent_deleted(title: str) -> None: + """Green ``deleted`` notice box (stderr): ``✓ deleted chat <title>``.""" + if _quiet: + return + body = Text() + body.append("✓ ", style=theme.SUCCESS) + body.append("deleted chat ", style=theme.TEXT) + body.append(title or "untitled", style=f"bold {theme.ACCENT}") + _notice_box(body, color=theme.SUCCESS, title="deleted") + + +def agent_tool_used(tool: str) -> None: + """A dim stderr activity line during ``ask``: ``· used tool: <tool>``.""" + if _quiet: + return + _plain(("· used tool: ", theme.FAINT), (tool or "?", theme.TEXT_DIM)) + + +def render_agent_answer(answer: str, *, model: Optional[str] = None) -> None: + """The ``agent ask`` answer (stdout, interactive): the assistant's reply rendered as Markdown + inside an ACCENT ``assistant`` panel — the same boxed shell as ``agent show``. Piped/non-tty + callers print the raw answer instead (so it stays a clean payload).""" + from rich.markdown import Markdown + + width = min(max(_stdout.width - 4, 40), 100) + title = Text("assistant", style=f"bold {theme.SUCCESS}") + if model: + title.append(f" · {model}", style=theme.LABEL) + body: Any = Markdown(answer) if (answer or "").strip() else Text("(no answer)", style=theme.TEXT_DIM) + panel = Panel(body, box=ROUNDED, border_style=theme.ACCENT, title=title, title_align="left", + padding=(0, 1), expand=False, width=width) + _stdout.print() + _stdout.print(Padding(panel, (0, 0, 0, 2))) + + +def render_agent_new_chat(chat_id: str) -> None: + """The ``ask`` new-chat pointer (stderr), boxed: ``↳ new chat <short>`` + a ``continue with + fp agent ask --chat <short> "…"`` line — the short handle resolves back to the full id.""" + if _quiet: + return + short = _short_chat_id(str(chat_id)) + line1 = Text() + line1.append("↳ new chat ", style=theme.FAINT) + line1.append(short, style=f"bold {theme.ACCENT}") + line2 = Text() + line2.append("continue with ", style=theme.FAINT) + line2.append(f'fp agent ask --chat {short} "…"', style=theme.ACCENT) + _notice_box(Group(line1, line2), color=theme.ACCENT, title="new chat") + + +def agent_error(message: str) -> None: + """Red ``error`` notice box (stderr) for an ``ask`` failure: ``✗ <message>`` (no raw HTTP).""" + body = Text() + body.append("✗ ", style=f"bold {theme.ERROR}") + body.append(message or "the assistant could not answer", style=theme.TEXT) + _notice_box(body, color=theme.ERROR, title="error") + + +def agent_chat_not_found(chat_id: str) -> None: + """Red ``error`` notice box (stderr): ``✗ chat not found "<handle>"`` + a dim ``agent chats`` + hint — the calm not-found for show/rename/delete/ask (a bad/short id that resolves to none).""" + body = Text() + body.append("✗ ", style=f"bold {theme.ERROR}") + body.append("chat not found ", style=theme.TEXT) + body.append(f'"{_short_chat_id(str(chat_id))}"', style=f"bold {theme.ACCENT}") + hint = Text() + hint.append("check the id, or run ", style=theme.FAINT) + hint.append("fp agent chats", style=theme.ACCENT) + hint.append(" to see your chats", style=theme.FAINT) + _notice_box(Group(body, hint), color=theme.ERROR, title="error") + + +def agent_chat_ambiguous(handle: str, matches: Sequence[str]) -> None: + """Red ``error`` notice box (stderr): a short chat-id prefix that matched more than one chat — + list the matches and ask for more characters.""" + body = Text() + body.append("✗ ", style=f"bold {theme.ERROR}") + body.append("ambiguous chat id ", style=theme.TEXT) + body.append(f'"{handle}"', style=f"bold {theme.ACCENT}") + body.append(" — matches: ", style=theme.TEXT) + body.append(", ".join(_short_chat_id(m) for m in matches), style=theme.TEXT_DIM) + hint = Text("use a few more characters of the id", style=theme.FAINT) + _notice_box(Group(body, hint), color=theme.ERROR, title="error") + + +def agent_unconfigured_note() -> None: + """A dim stderr note when the assistant isn't set up (used by ``models`` when none are reported): + ``the assistant isn't configured on this deployment — check fp agent health``.""" + if _quiet: + return + _plain(("the assistant isn't configured on this deployment — check ", theme.FAINT), + ("fp agent health", theme.ACCENT)) + _stderr.print() + + +# ══ audits renderers ══ +# Audits are scheduled sweeps that produce findings, so the group has TWO list surfaces (the +# definitions and their findings) plus a run history. It follows the alerts/incidents family: +# BOXES carry data (list panels, show cards, the green created/updated cards, the amber delete +# preview), ACTION FEEDBACK (✓ / ○ / ✗ / ⚠ confirm) is plain indented stderr lines. + +# Finding status → (marker, colour). open = untriaged (red ●), recurring = seen again (amber ●), +# resolved = fixed (green ○), dismissed/muted = suppressed (faint ○). Unknown → neutral dim dot. +_FINDING_STATUS = { + "open": ("●", theme.ERROR), + "recurring": ("●", theme.AMBER), + "resolved": ("○", theme.SUCCESS), + "dismissed": ("○", theme.FAINT), + "muted": ("○", theme.FAINT), +} + +# Run status → colour. running = in flight (amber), succeeded = green, failed = red. +_RUN_STATUS_COLORS = {"running": theme.AMBER, "succeeded": theme.SUCCESS, "failed": theme.ERROR} + +# Finding kind → colour. A small fixed enum: failure = something broke (red), policy = a rule +# violation (amber), improvement = an opportunity (neutral). Unknown kinds render neutral. +_FINDING_KIND_COLORS = {"failure": theme.ERROR, "policy": theme.AMBER, "improvement": theme.TEXT_DIM} + + +def _finding_status_cell(status: str, *, muted: bool = False) -> Text: + """Finding status as a colour-coded dot + word — the same dot vocabulary as keys/users/alerts/ + incidents. Suppressed states (dismissed/muted) and resolved use a HOLLOW ○ so the live ones + (open/recurring) stay distinguishable under NO_COLOR.""" + marker, color = _FINDING_STATUS.get((status or "").lower(), ("●", theme.TEXT_DIM)) + if muted: + color = theme.FAINT + t = Text() + t.append(marker + " ", style=color) + t.append(status or "-", style=color) + return t + + +def _run_status_cell(status: str) -> Text: + """Run status as a colour-coded word (running amber / succeeded green / failed red; unknown + neutral dim). Under NO_COLOR a ``!`` marks a failed run so it stays visible.""" + st = status or "-" + if _no_color and st == "failed": + st += "!" + return Text(st, style=_RUN_STATUS_COLORS.get(status or "", theme.TEXT_DIM)) + + +def _audit_last_run_cell(audit: Any, *, muted: bool = False) -> Text: + """The audits-list ``last run`` column: the compact age of the last finished run, tinted by + that run's status (``never`` when it has not run yet — e.g. a freshly created or disabled + audit).""" + age = _age_compact(getattr(audit, "last_run_finished_at", None)) or _age_compact( + getattr(audit, "last_attempted_at", None) + ) + if not age: + return Text("never", style=theme.FAINT) + if muted: + return Text(age, style=theme.FAINT) + status = (getattr(audit, "last_run_status", None) or "").lower() + return Text(age, style=_RUN_STATUS_COLORS.get(status, theme.TEXT_DIM)) + + +def render_audits(audits: Sequence[Any], *, show_id: bool = False) -> None: + """The ``audits list`` view (stdout): an ACCENT panel titled ``audits · {n} · newest first`` with + columns ``created · name · every · findings · status · last run``. ``every`` is the humanized + schedule interval; ``findings`` the open-finding count (pink when > 0 — that's the thing to act + on); status is ``● on``/``○ off``; ``last run`` the age of the last run tinted by its outcome + (``never`` if it has not run). Disabled audits dim entirely so live ones dominate. ``name`` is + the handle so it is never truncated; the raw id is hidden unless ``show_id``. The creator + (``created_by``) is in ``audits show`` / ``--json`` — it loses to the operational columns here.""" + items = sorted(audits, key=lambda a: getattr(a, "created_at", "") or "", reverse=True) + parsed = [_parse_iso(getattr(a, "created_at", "")) for a in items] + multi_year = len({p.year for p in parsed if p is not None}) > 1 + + header = (["id"] if show_id else []) + ["created", "name", "every", "findings", "status", "last run"] + rows = [] + for a in items: + disabled = not getattr(a, "enabled", True) + name_style = theme.TEXT_DIM if disabled else theme.TEXT + dim = theme.FAINT if disabled else theme.TEXT_DIM + open_findings = getattr(a, "open_findings", 0) or 0 + if disabled: + findings = Text(str(open_findings), style=theme.FAINT) + elif open_findings: + findings = Text(str(open_findings), style=theme.PINK) + else: + findings = Text("0", style=theme.TEXT_DIM) + row = [Text(_short_id(getattr(a, "id", "") or "-"), style=dim)] if show_id else [] + row += [ + Text(_fmt_user_joined(getattr(a, "created_at", ""), multi_year), style=dim), + Text(getattr(a, "name", "") or "-", style=name_style), + Text(humanize_secs(getattr(a, "schedule_interval_secs", None)), style=theme.BLUE if not disabled else dim), + findings, + _alert_status_cell(getattr(a, "enabled", True), muted=disabled), + _audit_last_run_cell(a, muted=disabled), + ] + rows.append(row) + + title = Text() + title.append("audits", style=f"bold {theme.ACCENT}") + title.append(f" · {len(items)} · newest first", style=theme.LABEL) + render_list_panel("audits", header=header, rows=rows, days=set(), order=None, + empty_message="no audits", title=title) + + +def audits_footer(audits: Sequence[Any]) -> None: + """Distribution summary under the audits box (stderr): ``{total} audits · {n} on · {m} off · + {f} open findings`` — the findings segment (pink) only when there are any to triage.""" + if _quiet: + return + total = len(audits) + on = sum(1 for a in audits if getattr(a, "enabled", True)) + findings = sum(int(getattr(a, "open_findings", 0) or 0) for a in audits) + line = Text(" ") + line.append(f"{total} audit{'' if total == 1 else 's'}", style=theme.LABEL) + line.append(" · ", style=theme.FAINT) + line.append(f"{on} on", style=theme.SUCCESS) + line.append(" · ", style=theme.FAINT) + line.append(f"{total - on} off", style=theme.TEXT_DIM) + if findings: + line.append(" · ", style=theme.FAINT) + line.append(f"{findings} open finding{'' if findings == 1 else 's'}", style=theme.PINK) + _stderr.print(line) + _stderr.print() + + +def _audit_identity_line(audit: Any) -> Text: + """The audit identity fragment: ``● enabled · every {interval} · {window} window · {n} open + findings`` (the finding count red when > 0 — it's the actionable number).""" + t = Text() + t.append_text(_alert_status_inline(getattr(audit, "enabled", True))) + t.append(" · ", style=theme.FAINT) + t.append("every ", style=theme.LABEL) + t.append(humanize_secs(getattr(audit, "schedule_interval_secs", None)), style=theme.BLUE) + t.append(" · ", style=theme.FAINT) + t.append(str(getattr(audit, "window_mode", "") or "-"), style=theme.TEXT_DIM) + t.append(" window", style=theme.LABEL) + n = int(getattr(audit, "open_findings", 0) or 0) + t.append(" · ", style=theme.FAINT) + t.append(str(n), style=f"bold {theme.ERROR}" if n > 0 else theme.TEXT_DIM) + t.append(f" open finding{'' if n == 1 else 's'}", style=theme.LABEL) + return t + + +def _kv_table(pairs: Sequence[tuple]) -> Table: + """A borderless ``label → value`` table for the audit show cards (label LABEL-dim, value the + caller's pre-styled ``Text``). Rows whose value is ``None`` are dropped by the caller.""" + table = Table(box=None, pad_edge=False, show_header=False) + table.add_column(style=theme.LABEL, no_wrap=True) + table.add_column(overflow="fold") + for label, value in pairs: + table.add_row(label, value) + return table + + +def _audit_scope_text(scope: Any) -> Text: + """The audit ``scope`` blob → one readable line: ``key: a, b`` segments for the common + list-valued filters, else compact JSON. ``everything`` when the scope is empty (no filter).""" + if not isinstance(scope, dict) or not scope: + return Text("everything", style=f"italic {theme.TEXT_DIM}") + t = Text() + first = True + for key, value in scope.items(): + if value in (None, "", [], {}): + continue + if not first: + t.append(" · ", style=theme.FAINT) + first = False + t.append(f"{key} ", style=theme.LABEL) + if isinstance(value, list): + t.append(", ".join(str(v) for v in value), style=theme.TEXT) + elif isinstance(value, dict): + t.append(_json.dumps(value, ensure_ascii=False), style=theme.TEXT_DIM) + else: + t.append(str(value), style=theme.TEXT) + return t if not first else Text("everything", style=f"italic {theme.TEXT_DIM}") + + +def _audit_config_cards(audit: Any) -> None: + """The shared ``schedule`` / ``scope`` / ``analysis`` / ``channels`` cards (stdout) used by both + ``audits show`` and the green created/updated result — so a written audit renders exactly the + way inspecting it does.""" + # `anchored to` is the fixed phase runs land on (anchor + N * interval), so a + # slow run or a manual "run now" can't drift the cadence. Legacy rows written + # before the column existed have none — those still drift, so say so rather + # than rendering a bare "-". + anchor = getattr(audit, "schedule_anchor", None) + anchor_cell = ( + Text(_anchor_compact(anchor), style=theme.BLUE) + if anchor + else Text("unanchored (drifts)", style=theme.TEXT_DIM) + ) + _alert_card( + Text("schedule", style=f"bold {theme.ACCENT}"), + _kv_table([ + ("runs every", Text(humanize_secs(getattr(audit, "schedule_interval_secs", None)), style=theme.BLUE)), + ("anchored to", anchor_cell), + ("window mode", Text(str(getattr(audit, "window_mode", "") or "-"), style=theme.TEXT)), + ("lookback", Text(humanize_secs(getattr(audit, "lookback_window_secs", None)), style=theme.BLUE)), + ]), + ) + + scope_rows = [("covers", _audit_scope_text(getattr(audit, "scope", None)))] + ignored = list(getattr(audit, "ignore_error_types", None) or []) + if ignored: + scope_rows.append(("ignores", Text(", ".join(str(i) for i in ignored), style=theme.TEXT_DIM))) + _alert_card(Text("scope", style=f"bold {theme.ACCENT}"), _kv_table(scope_rows)) + + llm = Text() + if getattr(audit, "llm_enabled", True): + llm.append("● ", style=theme.SUCCESS) + llm.append("on", style=theme.SUCCESS) + else: + llm.append("○ ", style=theme.TEXT_DIM) + llm.append("off", style=theme.TEXT_DIM) + _alert_card( + Text("analysis", style=f"bold {theme.ACCENT}"), + _kv_table([ + ("llm", llm), + ("sensitivity", Text(str(getattr(audit, "sensitivity", "") or "-"), style=theme.TEXT)), + ("top k", Text(str(getattr(audit, "top_k", "") or "-"), style=theme.PINK)), + ]), + ) + + channels = [c for c in (getattr(audit, "channels", None) or []) if isinstance(c, dict)] + all_default, table = _alert_channels_body(channels) + title = Text() + title.append("channels", style=f"bold {theme.ACCENT}") + title.append(" · " + ("default" if all_default else "custom"), style=theme.LABEL) + _alert_card(title, table) + + +def render_audit_show(audit: Any) -> None: + """The ``audits show <name>`` view (stdout): a stack of ACCENT cards — an identity card (name + + description, then ``● enabled · every {interval} · {window} window · {n} open findings`` and a + ``created by … · last run …`` line) followed by ``schedule`` / ``scope`` / ``analysis`` / + ``channels``, then a dim ``--json`` pointer. Presentation only.""" + title = Text() + title.append(getattr(audit, "name", "") or "-", style=f"bold {theme.ACCENT}") + title.append(" · audit", style=theme.LABEL) + + lines: List[Text] = [] + desc = (getattr(audit, "description", None) or "").strip() + if desc: + lines.append(Text(desc, style=theme.TEXT_DIM)) + lines.append(_audit_identity_line(audit)) + meta = Text() + meta.append("created by ", style=theme.LABEL) + meta.append(getattr(audit, "created_by", "") or "-", style=theme.TEXT) + last = _age_compact(getattr(audit, "last_run_finished_at", None)) + meta.append(" · ", style=theme.FAINT) + meta.append("last run ", style=theme.LABEL) + if last: + meta.append(last, style=theme.TEXT) + status = getattr(audit, "last_run_status", None) + if status: + meta.append(" (", style=theme.FAINT) + meta.append_text(_run_status_cell(str(status))) + meta.append(")", style=theme.FAINT) + else: + meta.append("never", style=theme.FAINT) + lines.append(meta) + if getattr(audit, "last_error", None): + err = Text() + err.append("last error ", style=theme.LABEL) + err.append(str(audit.last_error), style=theme.ERROR) + lines.append(err) + + _alert_card(title, Group(*lines)) + _audit_config_cards(audit) + + if not _quiet: + foot = Text(" ") + foot.append("view raw with ", style=theme.FAINT) + foot.append("--json", style=theme.ACCENT) + _stderr.print() + _stderr.print(foot) + _stderr.print() + + +def _render_audit_write_result(audit: Any, *, verb: str, old_name: Optional[str] = None) -> None: + """The shared GREEN ``audit created``/``audit updated`` card (stdout) + the same config cards + ``show`` renders, so a write always displays the audit's real saved state.""" + line1 = Text(getattr(audit, "name", "") or "-", style=f"bold {theme.TEXT}") + if old_name and old_name != getattr(audit, "name", ""): + line1.append(" was ", style=theme.FAINT) + line1.append(old_name, style=theme.FAINT) + rows = [line1] + desc = (getattr(audit, "description", None) or "").strip() + if desc: + rows.append(Text(desc, style=theme.TEXT_DIM)) + rows.append(_audit_identity_line(audit)) + line_last = Text() + line_last.append(f"{verb} by ", style=theme.LABEL) + line_last.append("you", style=theme.TEXT) + line_last.append(" · ", style=theme.FAINT) + line_last.append("just now", style=theme.LABEL) + rows.append(line_last) + + card = Panel(Group(*rows), box=ROUNDED, border_style=theme.SUCCESS, + title=Text(f"audit {verb}", style=f"bold {theme.SUCCESS}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print() + _stdout.print(Padding(card, (0, 0, 0, 2))) + _audit_config_cards(audit) + if not _quiet: + foot = Text(" ") + foot.append("run it now with ", style=theme.FAINT) + foot.append(f"fp audits run {getattr(audit, 'name', '') or ''}".rstrip(), style=theme.ACCENT) + _stderr.print() + _stderr.print(foot) + _stderr.print() + + +def render_audit_created(audit: Any) -> None: + """The ``audits create`` success view (stdout): the green ``audit created`` card + config cards.""" + _render_audit_write_result(audit, verb="created") + + +def render_audit_updated(audit: Any, *, old_name: Optional[str] = None) -> None: + """The ``audits edit`` success view (stdout): the green ``audit updated`` card (with `` was + {old}`` when renamed) + config cards.""" + _render_audit_write_result(audit, verb="updated", old_name=old_name) + + +def confirm_audit_edit(name: str) -> bool: + """Plain edit confirm (stderr): ``⚠ update {name}?`` + ``this replaces the audit's definition`` + + ``confirm? [y/N]`` (default NO). Returns the answer.""" + h = Text() + h.append("⚠ ", style=f"bold {theme.AMBER}") + h.append("update ", style=theme.TEXT) + h.append(name, style=f"bold {theme.ACCENT}") + h.append("?", style=theme.TEXT) + return confirm_line(h, Text("this replaces the audit's definition", style=theme.LABEL)) + + +def audit_exists(name: str) -> None: + """Red ``error`` notice box (stderr): ``✗ an audit named <name> already exists`` + a dim hint to + edit it instead. (Names are the handle, so they must stay unique.)""" + body = Text() + body.append("✗ ", style=f"bold {theme.ERROR}") + body.append("an audit named ", style=theme.TEXT) + body.append(name, style=f"bold {theme.ACCENT}") + body.append(" already exists", style=theme.TEXT) + hint = Text() + hint.append("pick a different name, or edit it with ", style=theme.FAINT) + hint.append(f"fp audits edit {name}", style=theme.ACCENT) + _notice_box(Group(body, hint), color=theme.ERROR, title="error") + + +def render_audit_delete_preview(audit: Any) -> None: + """The ``audits delete`` preview (stderr): an AMBER ``delete audit`` box — name + ``every + {interval} · {status}`` + the open-finding count (red when > 0, since the delete takes its + findings and run history with it).""" + line1 = Text(getattr(audit, "name", "") or "-", style=f"bold {theme.ACCENT}") + line2 = Text() + line2.append("every ", style=theme.LABEL) + line2.append(humanize_secs(getattr(audit, "schedule_interval_secs", None)), style=theme.BLUE) + line2.append(" · ", style=theme.FAINT) + line2.append_text(_alert_status_cell(getattr(audit, "enabled", True))) + line3 = Text() + n = int(getattr(audit, "open_findings", 0) or 0) + line3.append(str(n), style=f"bold {theme.ERROR}" if n > 0 else theme.TEXT_DIM) + line3.append(f" open finding{'' if n == 1 else 's'}", style=theme.LABEL) + card = Panel(Group(line1, line2, line3), box=ROUNDED, border_style=theme.AMBER, + title=Text("delete audit", style=f"bold {theme.AMBER}"), + title_align="left", padding=(0, 1), expand=False) + _stderr.print() + _stderr.print(Padding(card, (0, 0, 0, 2))) + + +def confirm_audit_delete(open_findings: int) -> bool: + """Plain delete confirm (stderr): ``⚠ this permanently removes the audit …`` (naming the + findings that go with it) + ``confirm? [y/N]`` — the amber preview box printed just above.""" + h = Text() + h.append("⚠ ", style=f"bold {theme.AMBER}") + if open_findings > 0: + h.append(f"this permanently removes the audit — its {open_findings} open " + f"finding{'' if open_findings == 1 else 's'} and run history go with it", + style=theme.LABEL) + else: + h.append("this permanently removes the audit and its run history — it can't be undone", + style=theme.LABEL) + return confirm_line(h) + + +def audit_deleted(name: str) -> None: + """Green ``deleted`` notice box (stderr): ``✓ deleted audit <name>``.""" + if _quiet: + return + body = Text() + body.append("✓ ", style=theme.SUCCESS) + body.append("deleted audit ", style=theme.TEXT) + body.append(name, style=f"bold {theme.ACCENT}") + _notice_box(body, color=theme.SUCCESS, title="deleted") + + +def audit_run_queued(name: str) -> None: + """Plain green line (stderr): ``✓ queued a run for {name}`` + a dim pointer at ``audits runs``. + The dispatcher picks the run up on its next tick, so this is a queue ack, not a result.""" + if _quiet: + return + _stderr.print() + _plain(("✓ ", theme.SUCCESS), ("queued a run for ", theme.TEXT), (name, theme.ACCENT)) + _plain(("it starts on the next dispatcher tick — watch it with ", theme.FAINT), + (f"fp audits runs {name}", theme.ACCENT)) + + +# Reference-snapshot states. Module-level and read with a neutral fallback, like +# every other status map in this file (`_STATUS_COLORS`, `_RUN_STATUS_COLORS`, +# `_SEVERITY_COLORS`) — the server owns this vocabulary and may add to it, so an +# unknown value must render dim rather than raise. +_REFERENCE_STATUS_COLORS = { + "ok": theme.SUCCESS, + "empty": theme.AMBER, + "pending": theme.AMBER, + "fetching": theme.AMBER, + "failed": theme.ERROR, + "blocked": theme.ERROR, +} + + +def _will_be_read(source: Dict[str, Any]) -> bool: + """Will the next run read this page? Mirrors ``load_for_run`` on the server. + + The predicate is "we hold text for it and the guard has not refused it", NOT + ``status == "ok"``. Status describes the last REFRESH: a page whose re-read is + in flight or whose re-read failed still has the snapshot taken last time, and + the server still sends it to the agent, because a refresh may improve a + snapshot and never withdraw one. + + Keying on ``ok`` reported a retained page as unreadable while it was in the + prompt — and suppressed its injection markers, which is the one thing an + operator is told to look at. + """ + return str(source.get("status") or "") != "blocked" and int(source.get("chars") or 0) > 0 + + +def audit_context(name: str, ctx: Dict[str, Any]) -> None: + """The audit's brief plus each reference URL's fetch state. + + Deliberately surfaces the three things that are otherwise invisible: that a + snapshot was truncated, that secret-shaped values were masked, and that a page + contains phrases reading as instructions to an AI. The last one is the reason + the operator gets to see the stored text at all. + """ + if _quiet: + return + text = str(ctx.get("text") or "") + sources = list(ctx.get("sources") or []) + _stderr.print() + _plain(("context for ", theme.TEXT), (name, theme.ACCENT)) + _stderr.print() + if text: + for line in text.splitlines() or [""]: + _plain((" ", theme.FAINT), (line, theme.TEXT)) + else: + _plain((" (no brief)", theme.FAINT)) + _stderr.print() + if not sources: + _plain((" (no reference URLs)", theme.FAINT)) + return + for s in sources: + status = str(s.get("status") or "") + markers = list(s.get("injection_markers") or []) + # A page carrying injection markers is amber, not green: it is in the + # prompt, and that is exactly why the operator has to look. + used = _will_be_read(s) + tone = _REFERENCE_STATUS_COLORS.get(status, theme.TEXT_DIM) + if markers and used: + tone = theme.AMBER + if markers and used: + label = "review" + elif status == "failed" and used: + label = "stale copy" + elif status == "fetching" and used: + label = "re-reading" + else: + label = status + _plain((" ", theme.FAINT), (f"[{label}]", tone), (" ", theme.TEXT), + (str(s.get("url") or ""), theme.ACCENT)) + bits = [] + if used: + bits.append(f"{s.get('chars', 0)} chars") + if s.get("truncated"): + bits.append(f"truncated from {s.get('chars_total', 0)}") + if s.get("redactions"): + bits.append(f"{s.get('redactions')} secret-shaped values masked") + if s.get("changed_at"): + bits.append("changed since last run") + # Both facts together, because either alone misleads: the copy is + # old AND it is still what the next run reads. + if status == "failed": + bits.append("last re-read failed; this copy is still used") + elif status == "fetching": + bits.append("re-reading now; this copy is used until it succeeds") + elif s.get("error_detail"): + bits.append(str(s.get("error_detail"))) + if bits: + _plain((" ", theme.FAINT), (" · ".join(bits), theme.FAINT)) + if markers: + _plain((" ", theme.FAINT), + (f"contains {len(markers)} phrase(s) that read as instructions to an AI — " + "read the snapshot before the next run", theme.AMBER)) + + +def audit_context_saved(name: str, result: Dict[str, Any]) -> None: + """Ack for ``audits context-set``. Says how many pages are being fetched, because + the save returns before the fetch does.""" + if _quiet: + return + queued = int(result.get("queued") or 0) + _stderr.print() + _plain(("✓ ", theme.SUCCESS), ("saved context for ", theme.TEXT), (name, theme.ACCENT)) + if queued: + _plain((f"fetching {queued} page(s) in the background — check with ", theme.FAINT), + (f"fp audits context-show {name}", theme.ACCENT)) + + +def audit_context_refreshed(name: str, result: Dict[str, Any]) -> None: + """Ack for ``audits context-refresh``.""" + if _quiet: + return + queued = int(result.get("queued") or 0) + _stderr.print() + _plain(("✓ ", theme.SUCCESS), (f"re-queued {queued} page(s) for ", theme.TEXT), (name, theme.ACCENT)) + + +def _run_duration(run: Any) -> str: + """A run's wall time (``started_at`` → ``finished_at``) as a compact ``12s``/``4m``; ``-`` while + it is still running or if either timestamp is unparsable.""" + start = _parse_iso(getattr(run, "started_at", "") or "") + end = _parse_iso(getattr(run, "finished_at", None) or "") + if start is None or end is None: + return "-" + secs = int(max(0.0, (end - start).total_seconds())) + if secs < 60: + return f"{secs}s" + if secs < 3600: + return f"{secs // 60}m" + return f"{secs // 3600}h" + + +def render_audit_runs(runs: Sequence[Any], *, name: str = "", show_id: bool = False) -> None: + """The ``audits runs <name>`` view (stdout): an ACCENT panel titled ``runs · {n} · {audit}`` with + columns ``started · status · trigger · findings · new · took``. ``findings`` is the run's total, + ``new`` the count first seen in that run (pink when > 0); ``took`` is the wall time (``-`` while + a run is still going). Server order (newest first) is preserved; ``--json`` carries the window, + ``stats``, ``report`` and any ``error``.""" + items = list(runs) + header = (["id"] if show_id else []) + ["started", "status", "trigger", "findings", "new", "took"] + rows = [] + for r in items: + new = int(getattr(r, "new_findings_count", 0) or 0) + row = [Text(_short_id(getattr(r, "id", "") or "-"), style=theme.TEXT_DIM)] if show_id else [] + row += [ + Text(_age_compact(getattr(r, "started_at", "")) or "-", style=theme.TEXT_DIM), + _run_status_cell(getattr(r, "status", "")), + Text(getattr(r, "trigger_kind", "") or "-", style=theme.TEXT_DIM), + Text(str(getattr(r, "findings_count", 0) or 0), style=theme.TEXT), + Text(str(new), style=theme.PINK if new else theme.TEXT_DIM), + Text(_run_duration(r), style=theme.TEXT_DIM), + ] + rows.append(row) + title = Text() + title.append("runs", style=f"bold {theme.ACCENT}") + title.append(f" · {len(items)}", style=theme.LABEL) + if name: + title.append(f" · {name}", style=theme.LABEL) + render_list_panel("runs", header=header, rows=rows, days=set(), order=None, + empty_message="no runs yet", title=title) + + +def audit_runs_footer(runs: Sequence[Any]) -> None: + """Distribution summary under the runs box (stderr): ``{n} runs · {s} succeeded · {f} failed · + {r} running`` — each segment present only when that status appears.""" + if _quiet: + return + total = len(runs) + counts: dict = {} + for r in runs: + st = (getattr(r, "status", "") or "").lower() + counts[st] = counts.get(st, 0) + 1 + line = Text(" ") + line.append(f"{total} run{'' if total == 1 else 's'}", style=theme.LABEL) + for st in ("succeeded", "failed", "running"): + n = counts.get(st, 0) + if n: + line.append(" · ", style=theme.FAINT) + line.append(f"{n} {st}", style=_RUN_STATUS_COLORS[st]) + _stderr.print(line) + _stderr.print() + + +def render_findings(findings: Sequence[Any], *, show_id: bool = False) -> None: + """The ``audits findings`` view (stdout): an ACCENT panel titled ``findings · {n} · highest + priority first`` with columns ``id · title · severity · status · kind · seen · last``. The id IS + the handle for ``audits finding``/the triage commands, so it's always shown — short by default, + full with ``--show-id``; ``seen`` is the occurrence count, ``last`` the age of ``last_seen_at``. + Server order (priority-desc) is preserved. Suppressed findings (dismissed/muted) dim entirely.""" + items = list(findings) + header = ["id", "title", "severity", "status", "kind", "seen", "last"] + ids = [((getattr(f, "id", "") or "-") if show_id else _short_id(getattr(f, "id", "") or "-")) + for f in items] + kinds = [getattr(f, "kind", "") or "-" for f in items] + seens = [str(getattr(f, "occurrences", 0) or 0) for f in items] + lasts = [_age_compact(getattr(f, "last_seen_at", "")) or "-" for f in items] + sevs = [getattr(f, "severity", "") or "-" for f in items] + # The title is the ONE flexible column, so it absorbs the leftover width: measure the fixed + # columns (header-aware, like the queries/settings lists) and truncate the title to the rest, + # rather than letting a long title push `seen`/`last` off a narrow terminal. + budget: Optional[int] = None + if items: + def _w(label, vals): + return max(len(label), max((len(v) for v in vals), default=0)) + fixed = (_w("id", ids) + _w("severity", sevs) + _w("status", ["● recurring"]) + + _w("kind", kinds) + _w("seen", seens) + _w("last", lasts)) + # Chrome = the inter-column padding (2 per column) + the panel border/padding/indent. + budget = max(SCORES_MIN_WIDTH, _stdout.width - fixed - (2 * len(header) + 8)) + rows = [] + for i, f in enumerate(items): + status = (getattr(f, "status", "") or "").lower() + muted = status in ("dismissed", "muted") + dim = theme.FAINT if muted else theme.TEXT_DIM + kind = kinds[i] + kind_style = theme.FAINT if muted else _FINDING_KIND_COLORS.get(kind, theme.TEXT_DIM) + title_text = getattr(f, "title", "") or "—" + if budget is not None: + title_text = _truncate(title_text, budget) + rows.append([ + Text(ids[i], style=dim), + Text(title_text, style=theme.FAINT if muted else theme.TEXT), + _severity_cell(sevs[i], muted=muted), + _finding_status_cell(getattr(f, "status", ""), muted=muted), + Text(kind, style=kind_style), + Text(seens[i], style=dim), + Text(lasts[i], style=dim), + ]) + title = Text() + title.append("findings", style=f"bold {theme.ACCENT}") + title.append(f" · {len(items)} · highest priority first", style=theme.LABEL) + render_list_panel("findings", header=header, rows=rows, days=set(), order=None, + empty_message="no findings", title=title) + + +def findings_footer(findings: Sequence[Any]) -> None: + """Distribution summary under the findings box (stderr): ``{total} findings · {n} open · {m} + recurring · … · {c} critical`` — status counts in their status colours, then the critical count + when any are critical.""" + if _quiet: + return + total = len(findings) + counts: dict = {} + for f in findings: + st = (getattr(f, "status", "") or "").lower() + counts[st] = counts.get(st, 0) + 1 + critical = sum(1 for f in findings if (getattr(f, "severity", "") or "") == "critical") + line = Text(" ") + line.append(f"{total} finding{'' if total == 1 else 's'}", style=theme.LABEL) + for st in ("open", "recurring", "resolved", "dismissed", "muted"): + n = counts.get(st, 0) + if n: + line.append(" · ", style=theme.FAINT) + line.append(f"{n} {st}", style=_FINDING_STATUS[st][1]) + if critical: + line.append(" · ", style=theme.FAINT) + line.append(f"{critical} critical", style=theme.ERROR) + _stderr.print(line) + _stderr.print() + + +def render_finding_show(finding: Any) -> None: + """The ``audits finding <id>`` view (stdout): a stack of ACCENT cards — an identity card (title + + short id, then ``severity · status · kind · magnitude``, ``seen {n}× · first … · last …``, the + owning audit and any assignee) followed by ``analysis`` (description + root cause), + ``recommendation`` (fix + expected impact + effort), ``scope`` and ``evidence`` — each omitted + when the finding carries nothing for it. Then a dim ``--json`` pointer.""" + title = Text() + title.append(getattr(finding, "title", "") or "finding", style=f"bold {theme.ACCENT}") + title.append(f" · {_short_id(getattr(finding, 'id', '') or '-')}", style=theme.LABEL) + + l1 = Text() + l1.append_text(_severity_cell(getattr(finding, "severity", "") or "-")) + l1.append(" · ", style=theme.FAINT) + l1.append_text(_finding_status_cell(getattr(finding, "status", ""))) + kind = getattr(finding, "kind", "") or "" + if kind: + l1.append(" · ", style=theme.FAINT) + l1.append(kind, style=_FINDING_KIND_COLORS.get(kind, theme.TEXT_DIM)) + magnitude = getattr(finding, "magnitude", None) + if magnitude: + l1.append(" · ", style=theme.FAINT) + l1.append(str(magnitude), style=theme.TEXT_DIM) + lines: List[Text] = [l1] + + l2 = Text() + l2.append("seen ", style=theme.LABEL) + l2.append(f"{int(getattr(finding, 'occurrences', 0) or 0)}×", style=theme.PINK) + first = _age_compact(getattr(finding, "first_seen_at", "")) + last = _age_compact(getattr(finding, "last_seen_at", "")) + if first: + l2.append(" · ", style=theme.FAINT) + l2.append("first ", style=theme.LABEL) + l2.append(first, style=theme.TEXT) + if last: + l2.append(" · ", style=theme.FAINT) + l2.append("last ", style=theme.LABEL) + l2.append(last, style=theme.TEXT) + lines.append(l2) + + l3 = Text() + l3.append("audit ", style=theme.LABEL) + l3.append(getattr(finding, "audit_name", "") or "—", style=theme.TEXT) + failure_type = getattr(finding, "failure_type", "") or "" + if failure_type: + l3.append(" · ", style=theme.FAINT) + l3.append(failure_type, style=theme.TEXT_DIM) + if getattr(finding, "assigned_to", None): + l3.append(" · ", style=theme.FAINT) + l3.append("assigned to ", style=theme.LABEL) + l3.append(str(finding.assigned_to), style=theme.TEXT) + lines.append(l3) + + _alert_card(title, Group(*lines)) + + analysis = [] + if (getattr(finding, "description", None) or "").strip(): + analysis.append(("what", Text(str(finding.description).strip(), style=theme.TEXT))) + if (getattr(finding, "root_cause_hypothesis", None) or "").strip(): + analysis.append(("likely cause", Text(str(finding.root_cause_hypothesis).strip(), style=theme.TEXT_DIM))) + if analysis: + _alert_card(Text("analysis", style=f"bold {theme.ACCENT}"), _kv_table(analysis)) + + fix = [] + if (getattr(finding, "recommendation", None) or "").strip(): + fix.append(("do", Text(str(finding.recommendation).strip(), style=theme.TEXT))) + if (getattr(finding, "expected_impact", None) or "").strip(): + fix.append(("impact", Text(str(finding.expected_impact).strip(), style=theme.SUCCESS))) + if (getattr(finding, "effort", None) or "").strip(): + fix.append(("effort", Text(str(finding.effort).strip(), style=theme.TEXT_DIM))) + if fix: + _alert_card(Text("recommendation", style=f"bold {theme.ACCENT}"), _kv_table(fix)) + + scope = getattr(finding, "scope", None) + if isinstance(scope, dict) and scope: + _alert_card(Text("scope", style=f"bold {theme.ACCENT}"), + _kv_table([("covers", _audit_scope_text(scope))])) + + evidence_rows = [] + evidence = getattr(finding, "evidence", None) + if isinstance(evidence, dict) and evidence: + evidence_rows.append(("sample", Text(_json.dumps(evidence, ensure_ascii=False), style=theme.TEXT_DIM))) + queries = list(getattr(finding, "evidence_queries", None) or []) + if queries: + evidence_rows.append(( + "queries", + Text("\n".join(q if isinstance(q, str) else _json.dumps(q, ensure_ascii=False) for q in queries), + style=theme.TEXT_DIM), + )) + if evidence_rows: + _alert_card(Text("evidence", style=f"bold {theme.ACCENT}"), _kv_table(evidence_rows)) + + if not _quiet: + foot = Text(" ") + foot.append("view raw with ", style=theme.FAINT) + foot.append("--json", style=theme.ACCENT) + _stderr.print() + _stderr.print(foot) + _stderr.print() + + +# Triage action → the past-tense word the ✓ line reports. +_TRIAGE_VERBS = { + "ack": "acknowledged", "mute": "muted", "dismiss": "dismissed", + "resolve": "resolved", "reopen": "reopened", "assign": "assigned", +} + + +def confirm_finding_action(action: str, finding_id: str, *, title: Optional[str] = None) -> bool: + """Plain triage confirm (stderr) for the suppressing/closing actions: ``⚠ {action} finding + {short id} ({title})?`` + what it does + ``confirm? [y/N]`` (default NO). Returns the answer.""" + h = Text() + h.append("⚠ ", style=f"bold {theme.AMBER}") + h.append(f"{action} finding ", style=theme.TEXT) + h.append(_short_id(finding_id or "-"), style=f"bold {theme.ACCENT}") + if title: + h.append(f" ({title})", style=theme.LABEL) + h.append("?", style=theme.TEXT) + consequence = { + "mute": "future runs stop surfacing this pattern", + "dismiss": "it's suppressed as not worth acting on", + "resolve": "it closes; a genuine recurrence re-opens as new", + }.get(action, "this changes the finding's status") + return confirm_line(h, Text(consequence, style=theme.LABEL)) + + +def finding_triaged(action: str, finding_id: str, *, assigned_to: Optional[str] = None) -> None: + """Plain green line (stderr): ``✓ {verb} finding {short id}`` — plus ``· {email}`` for an + assign. The past-tense verb comes from the triage-action map.""" + if _quiet: + return + _stderr.print() + verb = _TRIAGE_VERBS.get(action, action) + parts = [("✓ ", theme.SUCCESS), (f"{verb} finding ", theme.TEXT), + (_short_id(finding_id or "-"), theme.ACCENT)] + if assigned_to: + parts += [(" · ", theme.FAINT), (assigned_to, theme.TEXT_DIM)] + _plain(*parts) + + +# ══ end audits renderers ══ + + +def format_scores(scores: Optional[dict]) -> str: + """Compact one-line rendering of a score map, e.g. ``helpfulness=0.85``.""" + if not scores: + return "-" + return " ".join(f"{k}={_round(v)}" for k, v in scores.items()) + + +def _round(value: Any) -> str: + try: + return f"{float(value):.2f}" + except (TypeError, ValueError): + return str(value) + + +def _as_dict(item: Any) -> dict: + if dataclasses.is_dataclass(item) and not isinstance(item, type): + return dataclasses.asdict(item) + return dict(item) + + +def project_dicts(items: Sequence[Any], fields: Sequence[str]) -> list: + """Project dataclass items down to dicts holding only ``fields`` (in order).""" + return [{name: _as_dict(it).get(name) for name in fields} for it in items] + + +def project_rows(items: Sequence[Any], fields: Sequence[str]) -> List[list]: + """Table rows for ``items`` projected to ``fields`` (scores/dicts rendered compactly).""" + rows: List[list] = [] + for it in items: + d = _as_dict(it) + rows.append([_field_cell(name, d.get(name)) for name in fields]) + return rows + + +def _field_cell(name: str, value: Any) -> str: + if name == "scores": + return format_scores(value) + if isinstance(value, (dict, list)): + return _json.dumps(value, ensure_ascii=False) + return _cell(value) + + +# ── Cloud-managed enforcement ──────────────────────────────────────────────── + + +_EFFECT_STYLE = {"enforce": theme.SUCCESS, "observe": theme.AMBER} + +#: Eight levels is what a terminal row can show without becoming a chart. The +#: timeline is 24 hourly bins, so the whole day fits on one line beside a label. +_SPARK = "▁▂▃▄▅▆▇█" + + +def sparkline(values: Sequence[float]) -> str: + """A one-line bar strip. Flat-zero renders as the lowest block, not blank — + "nothing was blocked" and "no data" are different answers and must not look + the same.""" + vals = [max(0.0, float(v or 0)) for v in values] + if not vals: + return "" + peak = max(vals) + if peak <= 0: + return _SPARK[0] * len(vals) + return "".join(_SPARK[min(len(_SPARK) - 1, int(v / peak * (len(_SPARK) - 1)))] for v in vals) + + +def _effect(effect: str) -> Text: + return Text(effect, style=_EFFECT_STYLE.get(effect, theme.TEXT_DIM)) + + +def _policy_cell(ref: Any) -> Text: + t = Text(ref.id, style=theme.TEXT) + t.append(f" v{ref.version}", style=theme.TEXT_DIM) + return t + + +def render_policies(items: Sequence[Any]) -> None: + """``fp policies`` — every published VERSION, newest of each policy first. + + One row per version, not per policy, because that is what the endpoint + returns and what the dashboard's own library shows. The title carries both + numbers for the same reason the dashboard does (`policies/page.tsx` counts + distinct policies and captions them "N versions"): a policy republished + twenty times is one policy and twenty rows, and a bare "policies · 21" over + that table is a number nobody can act on. + """ + rows = [] + # (id, -version): every version of a policy sits together, newest first. + # Sorting on id alone left the versions in whatever order the server + # happened to return them. + for p in sorted(items, key=lambda x: (x.id, -x.version)): + state = Text("active", style=theme.SUCCESS) + if p.archived: + state = Text("archived", style=theme.FAINT) + elif p.disabled: + state = Text("disabled", style=theme.AMBER) + rows.append([ + Text(p.id, style=theme.TEXT), + Text(f"v{p.version}", style=theme.TEXT_DIM), + state, + Text(p.description or "", style=theme.TEXT_DIM), + ]) + distinct = len({p.id for p in items}) + title = Text() + title.append("policies", style=f"bold {theme.ACCENT}") + title.append(" · ", style=theme.FAINT) + title.append(str(distinct), style="bold white") + if len(rows) != distinct: + title.append(" · ", style=theme.FAINT) + title.append(f"{len(rows)} versions", style=theme.LABEL) + render_list_panel("policies", header=["policy", "version", "state", "description"], + rows=rows, days=set(), order=None, + empty_message="no policies published — `fp policies publish <id> <file>`", + last_col="ellipsis", title=title) + + +def render_policy_published(p: Any, *, carriers: Optional[dict] = None, + source_bytes: int = 0) -> None: + """``fp policies publish`` — what was written, and what now runs it. + + The first version showed the id, the version and a sha256, then claimed "not + deployed anywhere yet" from a HARDCODED argument — so it said that even when + earlier versions were deployed across the fleet. It was also the only thing + on screen that was not simply a restatement of the command, which is what + made the card hard to read: nothing told you what you had just published. + + Now it shows the description, the size, and the truth about deployment: + `carriers` maps machine id -> the version it currently runs, so this can say + which machines are on an older version and would need moving. + """ + line1 = Text(p.id, style=f"bold {theme.TEXT}") + line1.append(f" v{p.version}", style=f"bold {theme.ACCENT}") + body = [line1] + if p.description: + body.append(Text(p.description, style=theme.TEXT)) + body.append(Text()) + + meta = Text() + if source_bytes: + meta.append(f"{source_bytes:,} bytes", style=theme.LABEL) + meta.append(" · ", style=theme.FAINT) + meta.append("sha256 ", style=theme.LABEL) + meta.append((p.sha256 or "")[:12] + "…", style=theme.TEXT_DIM) + body.append(meta) + body.append(Text()) + + # Publishing changes nothing on any machine. An author who assumes otherwise + # ships a policy that is never enforced, so this line is the point of the + # card — but it has to be true, which means looking rather than assuming. + older = sorted(m for m, v in (carriers or {}).items() if v != p.version) + if not carriers: + note = Text("published, not deployed", style=theme.AMBER) + note.append(" — no machine runs this policy yet", style=theme.LABEL) + body.append(note) + cmd = Text(" fp fleet deploy <machine> --add ", style=theme.LABEL) + cmd.append(p.id, style=theme.ACCENT) + body.append(cmd) + elif older: + many = len(older) > 1 + note = Text(f"{len(older)} machine{'s' if many else ''}", style=theme.AMBER) + note.append(f" still {'run' if many else 'runs'} an older version: ", style=theme.LABEL) + note.append(", ".join(older[:3]) + ("…" if len(older) > 3 else ""), style=theme.TEXT_DIM) + body.append(note) + cmd = Text(" fp fleet deploy <machine> --add ", style=theme.LABEL) + cmd.append(f"{p.id}@{p.version}", style=theme.ACCENT) + body.append(cmd) + else: + note = Text("every machine carrying it is already on ", style=theme.LABEL) + note.append(f"v{p.version}", style=theme.SUCCESS) + body.append(note) + + card = Panel(Group(*body), box=ROUNDED, border_style=theme.SUCCESS, + title=Text("published", style=f"bold {theme.SUCCESS}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print(); _stdout.print(Padding(card, (0, 0, 0, 2))); _stdout.print() + + +def render_fleet(machines: Sequence[Any]) -> None: + """``fp fleet`` — every machine, what it is told to run, and whether it is alive. + + `intended` is what the control plane decided and `applied` is what the + machine collected; showing both is the point, because a machine can be + deployed-to and still enforcing an older set. + + `seen` is a separate question from either, and the one the table used to + leave out: a host can be perfectly in sync and dead. Without it a machine + that last reported seven days ago rendered identically to one that reported + a minute ago. + + Takes only the machine records. It used to take the deployments as well and + never read them — everything here comes from the machine. + """ + rows = [] + for m in sorted(machines, key=lambda x: x.machine_id): + seen = _compact_age(m.last_seen) + # Stale is a judgement the table can make once, rather than every reader + # doing the subtraction: a day is generous for a host that reports on + # every hook, and quiet enough to be worth a colour. + stale = m.last_seen is None or ( + datetime.now(timezone.utc).timestamp() - m.last_seen / 1000 > 86_400 + ) + rows.append([ + Text(m.machine_id, style=theme.TEXT), + Text(m.display_label or "-", style=theme.TEXT_DIM), + Text(str(m.policy_count), style=theme.TEXT if m.policy_count else theme.FAINT), + Text(f"#{m.deployment}" if m.deployment is not None else "—", style=theme.TEXT_DIM), + Text(f"#{m.applied_deployment}" if m.applied_deployment is not None else "—", + style=theme.AMBER if m.drifted else theme.TEXT_DIM), + Text(seen or "never", style=theme.FAINT if stale else theme.TEXT_DIM), + Text(f"{m.event_count:,}" if m.event_count else "—", + style=theme.TEXT_DIM if m.event_count else theme.FAINT), + Text("drifted" if m.drifted else ("ok" if m.deployed else "—"), + style=theme.AMBER if m.drifted else (theme.SUCCESS if m.deployed else theme.FAINT)), + ]) + title = Text() + title.append("fleet", style=f"bold {theme.ACCENT}") + title.append(" · ", style=theme.FAINT) + title.append(str(len(rows)), style="bold white") + render_list_panel("fleet", + header=["machine", "label", "pol", "intended", "applied", "seen", + "events", "state"], + rows=rows, days=set(), order=None, + empty_message="no machines have checked in yet", title=title) + + +def _compact_age(ms: Optional[int]) -> str: + """`5h`, `7d`, `just now` — the column form of `_epoch_age`. + + A table cell is not a sentence: "7 days ago" spends eleven characters saying + what "7d" says in two, and this column sits beside seven others. + """ + if not ms: + return "" + secs = max(0.0, datetime.now(timezone.utc).timestamp() - ms / 1000) + if secs < 90: + return "just now" + if secs < 3600: + return f"{int(round(secs / 60))}m" + if secs < 86_400: + return f"{int(round(secs / 3600))}h" + return f"{int(round(secs / 86_400))}d" + + +def _epoch_age(ms: Optional[int]) -> str: + """`_relative_age` for the machine record's epoch-ms timestamps. + + The deployment side of this API speaks ISO and the machine side speaks + milliseconds; rather than a second humaniser, convert and reuse the one the + errors card already uses so "2 hr ago" means the same thing everywhere. + """ + if not ms: + return "" + return _relative_age(datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat()) + + +def render_machine_policies(machine_id: str, dep: Any, machine: Any = None) -> None: + """``fp fleet show`` — what a machine is told to run, and whether it has it. + + The first version printed the id, the generation and the policy list, which + was a third of what the two endpoints return and quietly implied the machine + was running them. It can be told to run a policy it has never collected — + `appliedDeployment` is the field that says so, and leaving it out made this + view confidently wrong about the only thing it is asked. + + Deliberately NOT the deploy-plan renderer: that one talks about a change + ("N policies after this change"), which is a lie on a read-only view. + """ + body = [] + if machine is not None and machine.display_label: + body.append(Text(machine.display_label, style=f"bold {theme.TEXT}")) + body.append(Text()) + + def field(label: str, value: Text) -> None: + line = Text(f"{label:<13}", style=theme.LABEL) + line.append_text(value) + body.append(line) + + if dep is not None: + gen = Text(f"#{dep.deployment}", style=f"bold {theme.TEXT}") + if machine is not None: + applied = machine.applied_deployment + if applied is None: + gen.append(" · ", style=theme.FAINT) + gen.append("not yet collected", style=theme.AMBER) + elif machine.drifted: + gen.append(" · ", style=theme.FAINT) + gen.append(f"machine is on #{applied}", style=theme.AMBER) + else: + gen.append(" · ", style=theme.FAINT) + gen.append("collected", style=theme.SUCCESS) + field("deployment", gen) + who = Text(dep.updated_by or "unknown", style=theme.TEXT_DIM) + when = _relative_age(dep.updated_at) + if when: + who.append(f" · {when}", style=theme.LABEL) + field("deployed by", who) + else: + field("deployment", Text("none", style=theme.FAINT)) + + if machine is not None: + seen = _epoch_age(machine.last_seen) or "never" + act = Text(seen, style=theme.TEXT_DIM if machine.last_seen else theme.FAINT) + if machine.event_count: + act.append(f" · {machine.event_count} events", style=theme.LABEL) + field("last seen", act) + + body.append(Text()) + pols = sorted(dep.policies, key=lambda x: x.id) if dep is not None else [] + if pols: + width = max(len(p.id) for p in pols) + # `ver` is three characters and `v1` is two, so the version cell is + # padded to the header's width — otherwise the effect column steps left + # by one on every row and the table reads as misaligned. + vwidth = max(3, max(len(f"v{p.version}") for p in pols)) + head = Text(f" {'policy'.ljust(width)} {'ver'.ljust(vwidth)} effect", style=theme.LABEL) + body.append(head) + for p in pols: + row = Text(" ") + row.append(p.id.ljust(width), style=theme.TEXT) + row.append(f" {f'v{p.version}'.ljust(vwidth)} ", style=theme.TEXT_DIM) + row.append_text(_effect(p.effect)) + body.append(row) + else: + body.append(Text(" no policies deployed", style=theme.FAINT)) + + card = Panel(Group(*body), box=ROUNDED, border_style=theme.ACCENT, + title=Text(machine_id, style=f"bold {theme.ACCENT}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print(); _stdout.print(Padding(card, (0, 0, 0, 2))); _stdout.print() + + +def render_deploy_plan(plan: Any, *, applied: bool = False) -> None: + """The signature view: the FULL resulting set, with the diff marked. + + Unchanged rows are shown on purpose. The endpoint replaces everything, so + the set on screen is the set that will exist — hiding the untouched rows + would hide exactly the ones a mistake silently drops. + """ + lines = [] + for p in plan.added: + t = Text(" + ", style=theme.SUCCESS); t.append_text(_policy_cell(p)) + t.append(" "); t.append_text(_effect(p.effect)); lines.append(t) + for was, now in plan.changed: + t = Text(" ~ ", style=theme.AMBER); t.append_text(_policy_cell(now)) + t.append(" "); t.append_text(_effect(now.effect)) + t.append(f" (was v{was.version} {was.effect})", style=theme.FAINT); lines.append(t) + for p in plan.removed: + t = Text(" - ", style=theme.ERROR) + t.append(p.id, style=theme.TEXT_DIM); t.append(f" v{p.version}", style=theme.FAINT) + lines.append(t) + for p in plan.unchanged: + t = Text(" = ", style=theme.FAINT); t.append_text(_policy_cell(p)) + t.append(" "); t.append_text(_effect(p.effect)); lines.append(t) + if not lines: + lines = [Text(" (no policies)", style=theme.FAINT)] + + footer = Text() + n = len(plan.result) + footer.append(f"{n} ", style="bold white") + footer.append(f"polic{'y' if n == 1 else 'ies'} after this change", style=theme.LABEL) + lines.append(Text()) + lines.append(footer) + + head = Text(plan.machine_id, style=f"bold {theme.TEXT}") + if plan.base is not None: + head.append(f" · deployment {plan.base} → {plan.base + 1}", style=theme.TEXT_DIM) + else: + head.append(" · first deployment", style=theme.TEXT_DIM) + border = theme.SUCCESS if applied else theme.ACCENT + card = Panel(Group(head, Text(), *lines), box=ROUNDED, border_style=border, + title=Text("deployed" if applied else "deploy plan", style=f"bold {border}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print(); _stdout.print(Padding(card, (0, 0, 0, 2))); _stdout.print() + + +def render_guardrails(summary: dict, timeline: Optional[dict] = None) -> None: + """``fp guardrails`` — what actually happened, as opposed to what was intended.""" + totals = summary.get("totals") or {} + stat = Text() + stat.append(str(totals.get("evaluated", 0)), style="bold white") + stat.append(" evaluated ", style=theme.LABEL) + stat.append(str(totals.get("blocked", 0)), style=f"bold {theme.ERROR}") + stat.append(" blocked ", style=theme.LABEL) + stat.append(f"{totals.get('enforcingMachines', 0)}/{totals.get('reportingMachines', 0)}", + style="bold white") + stat.append(" machines enforcing", style=theme.LABEL) + body = [stat] + + if timeline: + series = (timeline.get("series") or [{}])[0].get("points") or [] + denies = [p.get("deny", 0) for p in series] + if denies: + spark = Text() + spark.append("denies ", style=theme.LABEL) + spark.append(sparkline(denies), style=theme.ERROR) + body.append(spark) + + card = Panel(Group(*body), box=ROUNDED, border_style=theme.ACCENT, + title=Text(f"guardrails · {summary.get('hours', 24)}h", + style=f"bold {theme.ACCENT}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print(); _stdout.print(Padding(card, (0, 0, 0, 2))) + + rows = [] + for p in summary.get("policies") or []: + rows.append([ + Text(str(p.get("policy") or "-"), style=theme.TEXT), + Text(str(p.get("fired", 0)), style=theme.TEXT_DIM), + Text(str(p.get("blocked", 0)), + style=theme.ERROR if p.get("blocked") else theme.FAINT), + Text(str(p.get("instructed", 0)), + style=theme.AMBER if p.get("instructed") else theme.FAINT), + Text(f"{p.get('p95Ms', 0)}ms", style=theme.TEXT_DIM), + ]) + # An explicit title: the default one appends "newest first", which is a + # claim about ordering this table does not make — it is ranked by policy, + # not by time. + ptitle = Text() + ptitle.append("by policy", style=f"bold {theme.ACCENT}") + ptitle.append(" · ", style=theme.FAINT) + ptitle.append(str(len(rows)), style="bold white") + render_list_panel("guardrails", header=["policy", "fired", "blocked", "instructed", "p95"], + rows=rows, days=set(), order=None, + empty_message="no decisions recorded in this window", + last_col="ellipsis", title=ptitle) + + +def policy_lifecycle_changed(policy_id: str, action: str) -> None: + """``✓ disabled policy <id>`` etc, in the shared green notice box. + + The plain ``success()`` line these used to print was the only two-step flow + in the CLI whose confirm and result did not match the boxed shape every + other destructive action uses. + """ + detail = { + # Terse on purpose: the CONFIRM box already carried the caveat and the + # reversal command. Repeating them here is what pushed this card onto a + # second line at 100 columns. + "disabled": "removed from every deployment carrying it", + "enabled": "restored to the deployments that lost it", + "archived": "carriers keep it until redeployed", + }[action] + body = Text() + body.append("✓ ", style=theme.SUCCESS) + body.append(f"{action} policy ", style=theme.TEXT) + body.append(policy_id, style=theme.ACCENT) + body.append(" · ", style=theme.FAINT) + body.append(detail, style=theme.LABEL) + _notice_box(body, color=theme.SUCCESS, title=action) + + +def deployment_applied(machine_id: str, generation: int, count: int) -> None: + """``✓ deployed N policies to <machine> · now on #G``.""" + body = Text() + body.append("✓ ", style=theme.SUCCESS) + body.append(f"deployed {count} polic{'y' if count == 1 else 'ies'} to ", style=theme.TEXT) + body.append(machine_id, style=theme.ACCENT) + body.append(" · ", style=theme.FAINT) + body.append(f"now on deployment #{generation}", style=theme.LABEL) + _notice_box(body, color=theme.SUCCESS, title="deployed") + + +def deployment_rolled_back(machine_id: str, restored: int, generation: int) -> None: + body = Text() + body.append("✓ ", style=theme.SUCCESS) + body.append("restored the set from ", style=theme.TEXT) + body.append(f"#{restored}", style=theme.ACCENT) + body.append(" on ", style=theme.TEXT) + body.append(machine_id, style=theme.ACCENT) + body.append(" · ", style=theme.FAINT) + body.append(f"minted as deployment #{generation}", style=theme.LABEL) + _notice_box(body, color=theme.SUCCESS, title="rolled back") + + +def machine_renamed(machine_id: str, label: str) -> None: + body = Text() + body.append("✓ ", style=theme.SUCCESS) + if not label.strip(): + # An empty label is not a rename to nothing — the server clears the + # override, and the machine falls back to its self-asserted label or its + # id. Reporting it as `labelled <machine> as ` described neither. + body.append("cleared the label on ", style=theme.TEXT) + body.append(machine_id, style=theme.ACCENT) + body.append(" · ", style=theme.FAINT) + body.append("it now shows as its own label, or its id", style=theme.LABEL) + else: + body.append("labelled ", style=theme.TEXT) + body.append(machine_id, style=theme.ACCENT) + body.append(" as ", style=theme.TEXT) + body.append(label, style=f"bold {theme.TEXT}") + body.append(" · ", style=theme.FAINT) + body.append("the machine id itself is unchanged", style=theme.LABEL) + _notice_box(body, color=theme.SUCCESS, title="renamed") + + +def deployment_unchanged(machine_id: str) -> None: + """A no-op deploy. Calm, not a warning — the desired state already holds.""" + body = Text() + body.append("= ", style=theme.FAINT) + body.append(machine_id, style=theme.ACCENT) + body.append(" already matches", style=theme.TEXT) + body.append(" · ", style=theme.FAINT) + body.append("nothing deployed", style=theme.LABEL) + _notice_box(body, color=theme.ACCENT, title="no change") + + +def render_decision_timeline(data: dict) -> None: + """``fp guardrails timeline`` — one row per bucket, with the numbers. + + Replaces two bare sparkline strings. A sparkline is a fine *accent* beside a + headline number, which is why the summary keeps one — but on its own it has + no axis, no scale and no counts, so it cannot answer the question the command + exists for: *when* did enforcement bite, and how hard. Two rows of blocks + told you a shape and nothing you could act on. + """ + points = (data.get("series") or [{}])[0].get("points") or [] + if not points: + info("no decisions recorded in this window") + return + + bucket_ms = data.get("bucketMs") or 3_600_000 + # Label by what the bucket actually spans: hourly buckets want a clock, + # multi-day ones want a date, and printing 09:00 for a 24-hour bucket is how + # a chart lies about its own resolution. + fmt = "%H:%M" if bucket_ms < 86_400_000 else "%d %b" + peak = max((p.get("total", 0) for p in points), default=0) + width = 18 + + rows = [] + for p in points: + total = p.get("total", 0) or 0 + deny = p.get("deny", 0) or 0 + instruct = p.get("instruct", 0) or 0 + when = datetime.fromtimestamp((p.get("t") or 0) / 1000, tz=timezone.utc).strftime(fmt) + + # Denies are drawn INSIDE the total bar rather than beside it, so the + # blocked share is legible without arithmetic. + filled = 0 if peak <= 0 else max(1, round(total / peak * width)) if total else 0 + den_cells = 0 if total <= 0 else min(filled, max(1, round(deny / total * filled)) if deny else 0) + bar = Text() + bar.append("█" * den_cells, style=theme.ERROR) + bar.append("█" * (filled - den_cells), style=theme.ACCENT) + bar.append("·" * (width - filled), style=theme.BAR_EMPTY) + + rows.append([ + Text(when, style=theme.TEXT_DIM), + bar, + Text(str(total) if total else "—", style=theme.TEXT if total else theme.FAINT), + Text(str(deny) if deny else "—", style=theme.ERROR if deny else theme.FAINT), + Text(str(instruct) if instruct else "—", style=theme.AMBER if instruct else theme.FAINT), + ]) + + totals = sum(p.get("total", 0) or 0 for p in points) + denies = sum(p.get("deny", 0) or 0 for p in points) + title = Text() + title.append("decisions", style=f"bold {theme.ACCENT}") + title.append(" · ", style=theme.FAINT) + title.append(f"{data.get('hours', 24)}h", style="bold white") + title.append(" · ", style=theme.FAINT) + title.append(f"{totals} evaluated", style=theme.LABEL) + title.append(" · ", style=theme.FAINT) + title.append(f"{denies} blocked", style=theme.ERROR if denies else theme.LABEL) + render_list_panel("timeline", header=["time", "activity", "total", "denied", "instructed"], + rows=rows, days=set(), order=None, + empty_message="no decisions recorded in this window", title=title) + hint("red is the blocked share of each bar · times are UTC") + + +_DECISION_STYLE = {"deny": theme.ERROR, "instruct": theme.AMBER, "allow": theme.SUCCESS} +_DECISION_GLYPH = {"deny": "✗", "instruct": "!", "allow": "✓"} + + +def render_policy_test(run: Any, *, tool: str, command: Optional[str] = None, + file_path: Optional[str] = None, + expected: Optional[str] = None) -> None: + """``fp policies test`` — the verdict, and which policy produced it. + + Leads with the overall decision because that is the question asked. The + per-policy rows follow, since a file may register several and only one of + them refusing is what matters. + """ + overall = run.decision + colour = _DECISION_STYLE.get(overall, theme.TEXT) + head = Text() + head.append(f"{_DECISION_GLYPH.get(overall, '·')} ", style=f"bold {colour}") + head.append(overall.upper(), style=f"bold {colour}") + subject = command or file_path or "(no input)" + ctx_line = Text() + ctx_line.append(f"{tool} ", style=theme.LABEL) + ctx_line.append(subject, style=theme.TEXT) + + rows = [] + for r in run.results: + if "error" in r: + rows.append(Text(f" ✗ {r.get('name')}: {r['error']}", style=theme.ERROR)) + continue + d = r.get("decision", "allow") + line = Text(" ") + line.append(_DECISION_GLYPH.get(d, "·"), style=_DECISION_STYLE.get(d, theme.TEXT)) + line.append(f" {r.get('name')}", style=theme.TEXT) + line.append(f" {d}", style=_DECISION_STYLE.get(d, theme.TEXT_DIM)) + if r.get("reason"): + line.append(f" · {r['reason']}", style=theme.LABEL) + rows.append(line) + + if expected is not None: + rows.append(Text()) + verdict = Text(" ") + if run.decision == expected: + verdict.append("✓ ", style=theme.SUCCESS) + verdict.append(f"matched --expect {expected}", style=theme.LABEL) + else: + verdict.append("✗ ", style=theme.ERROR) + verdict.append(f"expected {expected}, got {run.decision}", style=theme.ERROR) + rows.append(verdict) + + card = Panel(Group(head, ctx_line, Text(), *rows), box=ROUNDED, border_style=colour, + title=Text("policy test", style=f"bold {colour}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print(); _stdout.print(Padding(card, (0, 0, 0, 2))); _stdout.print() + hint("this is a dry run — it does not prove the daemon sends the same context") + + +def render_composed_policy(prompt: str, source: str, syntax: Any, + *, saved_to: Optional[str] = None) -> None: + """``fp policies compose`` — the draft, and whether it even parses. + + Prints the source rather than publishing it. A generated policy that + deploys itself is a generated policy nobody read. + """ + head = Text() + head.append("drafted from ", style=theme.LABEL) + head.append(prompt, style=theme.TEXT) + status = Text() + if syntax.ok and syntax.checked: + status.append("✓ ", style=theme.SUCCESS) + status.append("parses as JavaScript", style=theme.LABEL) + elif not syntax.checked: + status.append("· ", style=theme.FAINT) + status.append("not syntax-checked (node not found)", style=theme.LABEL) + else: + status.append("✗ ", style=theme.ERROR) + status.append("does NOT parse — review before publishing", style=theme.ERROR) + body = [head, status] + if saved_to: + saved = Text() + saved.append("saved to ", style=theme.LABEL) + saved.append(saved_to, style=theme.ACCENT) + body.append(saved) + card = Panel(Group(*body), box=ROUNDED, border_style=theme.ACCENT, + title=Text("draft policy", style=f"bold {theme.ACCENT}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print(); _stdout.print(Padding(card, (0, 0, 0, 2))); _stdout.print() + _stdout.print(source) + _stdout.print() + hint("review it, then `fp policies publish <id> <file>` — or re-run with --publish <id>") + + +def policy_published_brief(p: Any) -> None: + body = Text() + body.append("✓ ", style=theme.SUCCESS) + body.append("published ", style=theme.TEXT) + body.append(p.id, style=theme.ACCENT) + body.append(f" v{p.version}", style=theme.TEXT_DIM) + _notice_box(body, color=theme.SUCCESS, title="published") + + +def render_deployment_history(machine_id: str, entries: Sequence[dict]) -> None: + """``fp fleet history`` — one row per generation, newest first. + + Was a bare `print` per line, which put an unaligned wall of timestamps and + comma-joined ids on stdout while every other list in the CLI is a panel. + + The `change` column is the point of reading history at all: what moved + between this generation and the one below it. A reissue — the server + rewriting a deployment because a policy was disabled or re-enabled — shows + up as an ordinary +/- and is otherwise indistinguishable from an operator + deploy, which is worth being able to see. + """ + rows = [] + prev = None + # oldest first so each row can be diffed against the one before it, then + # reversed for display — newest first is how you read a history. + ordered = sorted(entries, key=lambda e: e.get("deployment") or 0) + diffs = {} + for e in ordered: + # Keyed by id, comparing (version, effect). Keying by `id@version` + # instead made an effect flip invisible: enforce → observe is a policy + # that STOPPED BLOCKING, and it rendered as "no change". It also split a + # version bump into a "+x" and a "-x" for the same policy, which reads + # as removed-and-re-added rather than moved. + cur = {p.get("id"): (p.get("version"), p.get("effect")) + for p in (e.get("policies") or [])} + if prev is None: + diffs[e.get("deployment")] = [("+", i) for i in sorted(cur)] + else: + diffs[e.get("deployment")] = ( + [("+", i) for i in sorted(set(cur) - set(prev))] + + [("-", i) for i in sorted(set(prev) - set(cur))] + + [("~", i) for i in sorted(set(cur) & set(prev)) if cur[i] != prev[i]] + ) + prev = cur + + newest_first = sorted(entries, key=lambda e: e.get("deployment") or 0, reverse=True) + # The shared time column: clock time, with the date folded in only when the + # rows span more than a day. Generations land seconds apart, so a date-only + # cell made twenty-one of them look identical. + tcells, days = _row_times([_parse_iso(e.get("updatedAt", "") or "") for e in newest_first]) + + for e, tcell in zip(newest_first, tcells): + gen = e.get("deployment") + pols = sorted(f"{p.get('id')}" for p in (e.get("policies") or [])) + change = Text() + for i, (sign, ref) in enumerate(diffs.get(gen) or []): + if i: + change.append(" ") + # Same vocabulary as the deploy plan: + added, ~ changed, - removed. + change.append(sign, style={"+": theme.SUCCESS, "~": theme.AMBER}.get( + sign, theme.ERROR)) + change.append(ref, style=theme.TEXT_DIM) + if not change.plain: + change = Text("no change", style=theme.FAINT) + rows.append([ + Text(f"#{gen}", style=theme.TEXT), + Text(tcell or (e.get("updatedAt", "") or "-"), style=theme.TEXT_DIM), + Text(str(len(pols)), style=theme.TEXT_DIM if pols else theme.FAINT), + change, + Text(", ".join(pols) or "(none)", style=theme.TEXT_DIM if pols else theme.FAINT), + ]) + title = Text() + title.append(machine_id, style=f"bold {theme.ACCENT}") + title.append(" · ", style=theme.FAINT) + title.append(f"{len(rows)} generations", style=theme.LABEL) + render_list_panel("history", header=["gen", "when", "n", "change", "policies"], + rows=rows, days=days, order=None, + empty_message="no deployment history", last_col="ellipsis", title=title) + + +def render_fleet_diff(rows: Sequence[dict]) -> None: + """``fp fleet diff`` — intent vs delivery, per machine. + + Was one `info()` line per machine plus a warning, which is fine for two + machines and unreadable for twenty. The drifted rows are the whole reason to + run it, so they carry the colour and the summary counts them. + """ + out = [] + for r in rows: + drifted = bool(r.get("drifted")) + intended = r.get("intended") + delivered = r.get("delivered") + out.append([ + Text(str(r.get("machineId") or "-"), style=theme.TEXT), + Text(f"#{intended}" if intended is not None else "—", + style=theme.TEXT_DIM if intended is not None else theme.FAINT), + Text(f"#{delivered}" if delivered is not None else "—", + style=theme.AMBER if drifted else (theme.TEXT_DIM if delivered is not None else theme.FAINT)), + Text("behind" if drifted else ("in sync" if intended is not None else "nothing deployed"), + style=theme.AMBER if drifted else (theme.SUCCESS if intended is not None else theme.FAINT)), + ]) + drifted_n = sum(1 for r in rows if r.get("drifted")) + title = Text() + title.append("drift", style=f"bold {theme.ACCENT}") + title.append(" · ", style=theme.FAINT) + title.append(f"{drifted_n}", style=f"bold {theme.AMBER if drifted_n else theme.SUCCESS}") + title.append(f" of {len(rows)} behind", style=theme.LABEL) + render_list_panel("diff", header=["machine", "intended", "applied", "state"], + rows=out, days=set(), order=None, + empty_message="no machines have checked in yet", title=title) + if drifted_n: + hint("a machine is 'behind' until it next polls — it is still enforcing its previous set") diff --git a/fp-cli/fp_cli/permissions.py b/fp-cli/fp_cli/permissions.py new file mode 100644 index 000000000..ec8e546e3 --- /dev/null +++ b/fp-cli/fp_cli/permissions.py @@ -0,0 +1,196 @@ +"""Permission catalogue (mirrors the server ``Permission`` enum + dashboard presets). + +Used to validate ``--permission`` / ``--add`` / ``--remove`` / ``--preset`` values +client-side so a typo is a clean usage error instead of a server 422. Kept in sync +with ``server/src/auth.rs`` (``Permission::all``) and +``dashboard/lib/permissionGroups.ts`` (presets). +""" + +from __future__ import annotations + +import re +from typing import List + +# The full assignable permission set, in the server's declared order. +ALL_PERMISSIONS: List[str] = [ + "events:add", + "events:read", + "keys:create", + "keys:read", + "keys:disable", + "keys:regenerate", + "keys:update", + "users:create", + "users:read", + "users:update", + "users:delete", + "evaluations:read", + "evaluations:trigger", + "dashboards:read", + "dashboards:write", + "dashboards:delete", + "queries:read", + "queries:write", + "queries:delete", + "queries:run", + "agent:use", + "settings:read", + "settings:write", + "alerts:read", + "alerts:write", + "issues:read", + "issues:create", + "issues:close", + "audits:read", + "audits:write", + "policies:read", + "policies:write", + "policies:pull", + "usage:read", + "orgs:admin", +] + +# Retired spellings. `incidents:*` was renamed to `issues:*`; the server still +# PARSES the old tokens forever (see Permission::from_str) so keys minted before +# the rename keep working. Accept them here too and normalize, or a script that +# has passed `--add incidents:read` for a year would start exiting 2 against a +# server that would happily have honoured it. +# +# `incidents:ack` gated ack + comment + assign + subscribe + resolve, which now +# span all three issues permissions — so it expands to all three rather than +# mapping to any one, matching expand_implied() on the server. +RETIRED_PERMISSION_ALIASES = { + "incidents:read": ["issues:read"], + "incidents:write": ["issues:create"], + "incidents:ack": ["issues:read", "issues:create", "issues:close"], + "alerts:ack": ["issues:read", "issues:create", "issues:close"], +} + + +def normalize_permissions(perms: List[str]) -> List[str]: + """Expand retired spellings to their current equivalents, order-preserving.""" + out: List[str] = [] + for p in perms: + for mapped in RETIRED_PERMISSION_ALIASES.get(p, [p]): + if mapped not in out: + out.append(mapped) + return out + +# orgs:admin is an instance-level grant, not assignable to an org API key or member +# through these commands; exclude it from what the CLI lets you grant. +ASSIGNABLE_PERMISSIONS: List[str] = [p for p in ALL_PERMISSIONS if p != "orgs:admin"] + +_READ_ONLY = [p for p in ALL_PERMISSIONS if p.endswith(":read")] +# Must match BUILTIN_PERMISSION_SETS["standard"] in server/src/ch_tenancy.rs and +# STANDARD_PERMS in dashboard/lib/permissionGroups.ts. `issues:read` arrives via +# _READ_ONLY (it ends in ":read"); create + close are added explicitly. Close is +# included because the retired `incidents:ack` this replaces already granted +# resolve — see migration 20260721000000. +_STANDARD = _READ_ONLY + [ + "evaluations:trigger", + "queries:run", + "issues:create", + "issues:close", + "agent:use", +] + +# Builtin permission-set presets (dashboard/lib/permissionGroups.ts). +PRESETS = { + "read-only": list(_READ_ONLY), + "standard": list(_STANDARD), + "admin": list(ASSIGNABLE_PERMISSIONS), + "clear": [], +} + +_ASSIGNABLE_SET = frozenset(ASSIGNABLE_PERMISSIONS) + +# Permissions that parse + are user-assignable but must NEVER sit on an API key — the server's +# `Permission::key_assignable` rejects them (422). `orgs:admin` is already excluded above; +# `keys:update` is human-only (a bearer key can create keys but never edit them). The key flows +# strip these from a permission-set seed (silently, like the dashboard's keyAssignableOnly-strip) +# and reject them from an explicit `--add` (so the user gets a clean message, not a server 422). +KEY_NON_ASSIGNABLE = frozenset({"orgs:admin", "keys:update"}) +KEY_ASSIGNABLE_PERMISSIONS: List[str] = [p for p in ASSIGNABLE_PERMISSIONS if p not in KEY_NON_ASSIGNABLE] + + +def unknown_permissions(perms: List[str]) -> List[str]: + """Return any tokens that are not assignable permissions (empty if all valid). + + Retired spellings count as VALID. The server parses them (aliasing to the + current names), so rejecting them here would make the CLI stricter than the + API it fronts — a script that has passed `--add incidents:read` for a year + would start failing with exit 2 against a server that would have accepted it. + Callers should run the values through `normalize_permissions` before sending. + """ + return [ + p + for p in perms + if p not in _ASSIGNABLE_SET and p not in RETIRED_PERMISSION_ALIASES + ] + + +def key_assignable_only(perms) -> List[str]: + """Drop the human-only permissions (`keys:update` / `orgs:admin`) from a permission list — + used when SEEDING a key from a permission set, mirroring the dashboard's keyAssignableOnly + strip. Order-preserving.""" + return [p for p in perms if p not in KEY_NON_ASSIGNABLE] + + +def parse_key_permission_tokens(tokens, *, require_nonempty: bool = True) -> List[str]: + """Like :func:`parse_permission_tokens`, but for an API KEY — additionally rejects the + human-only permissions (`keys:update` / `orgs:admin`) with a clear message, so an explicit + `--add keys:update` fails client-side (exit 2) instead of hitting a server 422.""" + flat = parse_permission_tokens(tokens, require_nonempty=require_nonempty) + forbidden = [p for p in flat if p in KEY_NON_ASSIGNABLE] + if forbidden: + raise PermissionTokenError( + f'{", ".join(sorted(set(forbidden)))} can\'t be granted to an API key — it\'s human-only' + ) + return flat + + +def expand_preset(name: str) -> List[str]: + """Return the permissions for a builtin preset, or raise KeyError.""" + return list(PRESETS[name]) + + +class PermissionTokenError(Exception): + """A malformed or unknown compact ``slug:act.act`` permission token (carries a + user-facing message). Shared by ``keys`` and ``users`` so both surfaces parse the + compact token format identically.""" + + +def parse_permission_tokens(tokens, *, require_nonempty: bool = True) -> List[str]: + """Expand the compact ``slug:act1.act2`` permission tokens into a de-duplicated flat + assignable permission list (``events:read.add`` → ``events:read``, ``events:add``). Tokens + separated by **whitespace or commas** within one value AND repeated flags all compose — so + ``-p "a b"``, ``--add a,b``, and ``--add a --add b`` are equivalent. Raises + :class:`PermissionTokenError` on a malformed token or an unknown permission, and — when + ``require_nonempty`` — on an empty set (``users`` ``--add``/``--remove`` may be empty, so + they call the helper only when a value is supplied).""" + flat: List[str] = [] + seen: set = set() + for raw in tokens or []: + for tok in re.split(r"[\s,]+", str(raw).strip()): # whitespace- or comma-separated + if not tok: + continue # stray empties from `a,,b` / whitespace-only input + if ":" not in tok: + raise PermissionTokenError(f'bad permission "{tok}" — expected slug:action (e.g. events:read.add)') + slug, _, actions_str = tok.partition(":") + slug = slug.strip() + actions = [a.strip() for a in actions_str.split(".")] + if not slug or not actions_str.strip() or any(not a for a in actions): + raise PermissionTokenError(f'bad permission "{tok}" — no action after \':\' (e.g. {slug or "events"}:read.add)') + for a in actions: + perm = f"{slug}:{a}" + if perm not in seen: + seen.add(perm) + flat.append(perm) + if not flat: + if require_nonempty: + raise PermissionTokenError("at least one permission is required (e.g. events:read.add)") + return flat + unknown = unknown_permissions(flat) + if unknown: + raise PermissionTokenError(f'unknown permission(s): {", ".join(unknown)}') + return flat diff --git a/fp-cli/fp_cli/policy_check.py b/fp-cli/fp_cli/policy_check.py new file mode 100644 index 000000000..3c295e67f --- /dev/null +++ b/fp-cli/fp_cli/policy_check.py @@ -0,0 +1,241 @@ +"""Check a policy before it reaches a fleet. + +Nothing between an author and a machine validates policy source today. The CLI +rejects a NUL byte, the server checks the id charset and a 1 MiB ceiling — and +neither looks at whether the file is parseable JavaScript at all. So this +publishes, deploys, and reaches every machine in the fleet: + + echo 'this is not javascript {{{' | fp policies publish broken + +It then fails at enforcement time, on the machine, where nobody is watching. +That is the worst available place for a syntax error to surface, which is what +these two checks exist to move. + +``check_syntax`` is the cheap one and runs before every publish. ``run_policy`` +is the deliberate one behind ``fp policies test``: it actually executes the +policy against a context you describe, so an author — human or agent — can see +allow/deny/instruct before anyone's machine does. + +Both shell out to ``node``. Neither makes it a hard dependency: a machine +without node still publishes, with a stated reason rather than a silent skip. +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import re +import tempfile +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +#: Long enough for a cold node start on a loaded laptop, short enough that a +#: policy with an accidental infinite loop fails the command instead of hanging +#: it. A policy that cannot decide in five seconds cannot sit on a hook either. +_TIMEOUT_SECS = 5 + +#: SGR escapes node emits around its stack frames. +_ANSI = re.compile(r"\x1b\[[0-9;]*m") + + +def node_available() -> bool: + return shutil.which("node") is not None + + +@dataclass +class SyntaxResult: + ok: bool + #: None when the check could not run at all (no node). Distinct from `ok`, + #: because "we did not look" must never render as "we looked and it passed". + checked: bool + message: str = "" + + def to_dict(self) -> Dict[str, Any]: + return {"ok": self.ok, "checked": self.checked, "message": self.message} + + +def check_syntax(source: str) -> SyntaxResult: + """Parse-check policy source with ``node --check``. + + Written to a ``.mjs`` file so node parses it as a module: policies are ESM + (`import { deny } from "failproofai"`), and checking that as a script would + reject every real policy for using `import`. + """ + if not node_available(): + return SyntaxResult( + ok=True, checked=False, + message="node was not found on PATH, so the policy was not syntax-checked", + ) + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "policy.mjs") + with open(path, "w", encoding="utf-8") as fh: + fh.write(source) + try: + proc = subprocess.run( + ["node", "--check", path], + capture_output=True, text=True, timeout=_TIMEOUT_SECS, + ) + except subprocess.TimeoutExpired: + return SyntaxResult(ok=False, checked=True, + message="the syntax check timed out") + except OSError as exc: + return SyntaxResult(ok=True, checked=False, + message=f"could not run node ({exc}); source not checked") + if proc.returncode == 0: + return SyntaxResult(ok=True, checked=True) + # node prints the offending line, a caret, the SyntaxError — and then its own + # internal stack and version banner. The first three are the whole value of + # the check; the rest is node talking about itself inside an error box about + # the user's policy. + raw = (proc.stderr or proc.stdout or "").strip().replace(path, "<policy>") + # node colourises its stack frames, so the marker lines arrive with ANSI + # prefixes and a plain startswith() sails straight past them. Strip escapes + # before matching, and from the kept text too — this is going inside a box + # the CLI is already styling. + raw = _ANSI.sub("", raw) + keep = [] + for line in raw.splitlines(): + stripped = line.strip() + if stripped.startswith("at ") or stripped.startswith("Node.js v"): + break + keep.append(line) + detail = "\n".join(keep).strip() or raw + return SyntaxResult(ok=False, checked=True, message=detail) + + +#: A stand-in for the `failproofai` package a policy imports. Policies are +#: authored against the real one; this provides the same three helpers and +#: collects what `customPolicies.add` registers, so a policy can be executed +#: without installing anything. +_SHIM = """\ +export const allow = (reason) => reason ? { decision: "allow", reason } : { decision: "allow" }; +export const deny = (reason) => ({ decision: "deny", reason }); +export const instruct = (reason) => ({ decision: "instruct", reason }); +export const registered = []; +export const customPolicies = { add: (p) => { registered.push(p); } }; +export default { allow, deny, instruct, customPolicies }; +""" + +_RUNNER = """\ +import { registered } from "failproofai"; +import "./policy.mjs"; + +const ctx = JSON.parse(process.argv[2]); +const out = []; +for (const p of registered) { + try { + const r = await p.fn(ctx); + out.push({ name: p.name ?? "(unnamed)", description: p.description ?? null, + decision: r?.decision ?? "allow", reason: r?.reason ?? null }); + } catch (e) { + out.push({ name: p.name ?? "(unnamed)", error: String(e && e.message || e) }); + } +} +process.stdout.write(JSON.stringify({ policies: out })); +""" + + +@dataclass +class PolicyRun: + ok: bool + results: List[Dict[str, Any]] + error: str = "" + + @property + def decision(self) -> str: + """The strictest decision any policy returned. + + deny beats instruct beats allow, because that is how a fleet of policies + composes: one refusal is a refusal regardless of what the others said. + """ + decisions = [r.get("decision") for r in self.results if "decision" in r] + for level in ("deny", "instruct"): + if level in decisions: + return level + return "allow" + + def to_dict(self) -> Dict[str, Any]: + return {"ok": self.ok, "decision": self.decision, + "policies": self.results, "error": self.error} + + +def run_policy( + source: str, + *, + tool: str = "Bash", + command: Optional[str] = None, + file_path: Optional[str] = None, + event: str = "PreToolUse", + tool_input: Optional[Dict[str, Any]] = None, +) -> PolicyRun: + """Execute a policy against one synthetic context and report each verdict. + + Runs in a temp directory with the shim beside it, so the policy's + `import ... from "failproofai"` resolves without a node_modules anywhere. + Nothing is installed and nothing outside the temp directory is written. + + This is a DRY RUN, not the enforcement path: it proves the policy parses, + registers, and returns a decision for the input described. It cannot prove + the daemon will feed it the same context. + """ + if not node_available(): + return PolicyRun(ok=False, results=[], + error="node was not found on PATH, so the policy could not be run") + + payload: Dict[str, Any] = dict(tool_input or {}) + if command is not None: + payload.setdefault("command", command) + if file_path is not None: + payload.setdefault("file_path", file_path) + ctx = {"eventType": event, "toolName": tool, "toolInput": payload, "payload": payload} + + with tempfile.TemporaryDirectory() as tmp: + # The shim goes in `node_modules/failproofai/` rather than beside the + # policy, so the bare specifier a real policy writes — + # `import { deny } from "failproofai"` — resolves by node's ordinary + # lookup. The policy under test is then byte-identical to the one that + # gets published; an import map would have meant testing a rewritten + # file, and import-map support also varies by node version. + pkg = os.path.join(tmp, "node_modules", "failproofai") + os.makedirs(pkg) + with open(os.path.join(pkg, "package.json"), "w", encoding="utf-8") as fh: + fh.write(json.dumps({"name": "failproofai", "version": "0.0.0", + "type": "module", "main": "index.mjs", + "exports": "./index.mjs"})) + with open(os.path.join(pkg, "index.mjs"), "w", encoding="utf-8") as fh: + fh.write(_SHIM) + for name, body in (("policy.mjs", source), ("run.mjs", _RUNNER)): + with open(os.path.join(tmp, name), "w", encoding="utf-8") as fh: + fh.write(body) + with open(os.path.join(tmp, "package.json"), "w", encoding="utf-8") as fh: + fh.write(json.dumps({"type": "module"})) + try: + proc = subprocess.run( + ["node", "run.mjs", json.dumps(ctx)], + cwd=tmp, capture_output=True, text=True, timeout=_TIMEOUT_SECS, + ) + except subprocess.TimeoutExpired: + return PolicyRun(ok=False, results=[], + error=f"the policy did not finish within {_TIMEOUT_SECS}s") + except OSError as exc: + return PolicyRun(ok=False, results=[], error=f"could not run node: {exc}") + + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or "").strip().splitlines() + return PolicyRun(ok=False, results=[], + error="\n".join(detail[:6]) or "the policy failed to run") + try: + data = json.loads(proc.stdout or "{}") + except json.JSONDecodeError: + return PolicyRun(ok=False, results=[], error="the policy produced no readable result") + + results = data.get("policies") or [] + if not results: + return PolicyRun( + ok=False, results=[], + error=("the file registered no policies — a policy calls " + "`customPolicies.add({...})`; check it does, and that the call runs " + "at import time"), + ) + return PolicyRun(ok=True, results=results) diff --git a/fp-cli/fp_cli/py.typed b/fp-cli/fp_cli/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/fp-cli/fp_cli/select.py b/fp-cli/fp_cli/select.py new file mode 100644 index 000000000..92175c205 --- /dev/null +++ b/fp-cli/fp_cli/select.py @@ -0,0 +1,322 @@ +"""Interactive selection helpers (TTY pickers), shared across commands. + +Kept in one place so the org picker used at ``login`` and at ``orgs switch`` reads +and behaves identically. Pure presentation + input; no network, no persistence. +""" + +from __future__ import annotations + +import os +import sys +from typing import List, Optional, Sequence + +from . import output + + +def stdin_is_tty() -> bool: # noqa: D401 + """True iff stdin is an interactive terminal (so we may prompt for a choice). + + Factored out so it can be stubbed in tests — the CliRunner's stdin is not a + TTY, so the interactive pickers are otherwise unreachable under test. + """ + try: + return sys.stdin.isatty() + except Exception: + return False + + +def choose_org(slugs: Sequence[str], default: Optional[str] = None) -> str: + """Render ``slugs`` and prompt until the user picks one (by slug or number). + + ``default`` (only if it is one of ``slugs``) is the Enter-to-keep choice and is + marked ``· current`` in the list. Re-prompts on any out-of-range / unknown input, + so the return value is always one of ``slugs`` — a non-member slug can never be + selected through the picker. + """ + slugs = list(slugs) + output.org_picker(slugs, current=default) + prompt_default = default if (default in slugs) else None + while True: + choice = str(output.prompt("org", default=prompt_default)).strip() + if choice in slugs: + return choice + if choice.isdigit() and 1 <= int(choice) <= len(slugs): + return slugs[int(choice) - 1] + output.warn(f" '{choice}' is not one of your orgs — try again.") + + +# ── orgs switch — arrow-key picker (raw mode) + numbered fallback ───────────── + + +def _supports_raw_picker() -> bool: + """True iff we can run the raw-mode arrow picker: a real interactive TTY on both stdin + (we read keys) and stderr (we draw the menu), and POSIX ``termios`` is importable. + Anything else — pipes, CI, the test runner, Windows — falls back to the numbered prompt + (so the picker never hangs or crashes off a TTY, per the handoff spec §3/§7).""" + try: + import termios # noqa: F401 + except Exception: + return False + try: + return bool(sys.stdin.isatty() and sys.stderr.isatty()) + except Exception: + return False + + +def _read_key(fd: int) -> str: + """Read one logical keypress from a raw-mode ``fd`` → a token: ``UP``/``DOWN``/``ENTER``/ + ``ESC``/``EOF`` or the raw character. Distinguishes a lone Esc from an arrow escape + sequence (``\\x1b[A``) with a tiny ``select`` timeout so Esc never blocks.""" + import select as _sel + + ch = os.read(fd, 1) + if not ch: + return "EOF" + if ch == b"\x1b": # Esc, or the start of an arrow escape sequence + r, _, _ = _sel.select([fd], [], [], 0.0008) + if not r: + return "ESC" + seq = os.read(fd, 2) + return {b"[A": "UP", b"[B": "DOWN", b"[C": "RIGHT", b"[D": "LEFT"}.get(seq, "ESC") + if ch in (b"\r", b"\n"): + return "ENTER" + if ch == b"\x03": # Ctrl-C (also raised as KeyboardInterrupt under cbreak) + return "CTRL_C" + return ch.decode("utf-8", "ignore") + + +def _numbered_pick(orgs: Sequence[dict], *, current: Optional[str]) -> str: + """Non-TTY fallback: a boxed numbered list + a typed choice (slug or number). Re-prompts on + bad input, so the return is always one of the orgs' slugs. Default is the current org. With + no input at all (closed/empty stdin, e.g. CI) the prompt aborts → a clean usage error so the + run never hangs.""" + import typer + + from . import _click_compat as click # the Click Typer is running + + slugs: List[str] = [o["slug"] for o in orgs] + output.render_org_picker_numbered(orgs, current=current) + default = current if current in slugs else None + while True: + try: + choice = str(output.prompt("org", default=default)).strip() + except (click.Abort, EOFError): + raise typer.BadParameter( + "No org selected and no interactive terminal. " + "Pass a slug, e.g. `fp orgs switch <slug>`." + ) + if choice in slugs: + return choice + if choice.isdigit() and 1 <= int(choice) <= len(slugs): + return slugs[int(choice) - 1] + output.warn(f" '{choice}' is not one of your orgs — try again.") + + +def choose_org_interactive(orgs: Sequence[dict], *, current_slug: Optional[str] = None) -> Optional[str]: + """Pick an org to switch to. ``orgs`` is a list of ``{"slug", "is_current"}`` dicts. Returns + the chosen slug, or ``None`` if the user cancelled (Esc / Ctrl-C). On a real TTY this is an + in-place arrow-key menu (cursor starts on the current org); otherwise it falls back to a + numbered prompt (which can't cancel — it always returns a slug).""" + orgs = list(orgs) + if not _supports_raw_picker(): + return _numbered_pick(orgs, current=current_slug) + + import termios + import tty + + from rich.live import Live + + idx = next((i for i, o in enumerate(orgs) if o.get("is_current")), 0) + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + try: + tty.setcbreak(fd) + with Live(output.org_picker_frame(orgs, idx), console=output._stderr, + auto_refresh=False, transient=True) as live: + while True: + key = _read_key(fd) + if key in ("UP", "k"): + idx = (idx - 1) % len(orgs) + elif key in ("DOWN", "j"): + idx = (idx + 1) % len(orgs) + elif key == "ENTER": + return orgs[idx]["slug"] + elif key in ("ESC", "CTRL_C", "q", "EOF"): + return None + else: + continue + live.update(output.org_picker_frame(orgs, idx)) + live.refresh() + except KeyboardInterrupt: + return None + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + + +# ── login — the single-box interactive flow (one Live panel, raw-mode in-box input) ── + + +class LoginCancelled(Exception): + """The user pressed Esc / Ctrl-C during the login flow (calm cancel, not an error).""" + + +def login_box_supported() -> bool: + """True iff the single-box interactive login can run (real TTY on stdin+stderr + termios) — + otherwise ``login`` uses the plain prompt flow (tests, pipes, CI, ``--json``).""" + return _supports_raw_picker() + + +class LoginBox: + """Drives the single-box ``login``: ONE Rich ``Live`` panel redrawn in place as steps advance. + email/code are read in **raw mode** (echo off) and rendered INSIDE the frame; the org step is the + shared arrow picker as a nested inset. Restores the terminal on exit. Used only on a real TTY — + the non-interactive path keeps the plain prompts.""" + + def __init__(self) -> None: + self.done: List = [] # collapsed ✓ steps: (label, value) + self.active: Optional[str] = None + self._fd = sys.stdin.fileno() + self._old = None + self._live = None + + def __enter__(self) -> "LoginBox": + import termios + import tty + + from rich.live import Live + + self._old = termios.tcgetattr(self._fd) + tty.setcbreak(self._fd) # canonical + echo off, signals on (Ctrl-C → KeyboardInterrupt) + self._live = Live(output.render_login_frame(self.done, None), console=output._stderr, + auto_refresh=False, transient=False) + self._live.__enter__() + return self + + def __exit__(self, *exc) -> None: + import termios + + try: + if self._live is not None: + self._live.__exit__(*exc) + finally: + termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old) + + def _draw(self, **kw) -> None: + self._live.update(output.render_login_frame(self.done, self.active, **kw)) + self._live.refresh() + + def _read_line(self, *, helper, slots, error) -> str: + """Read one line in raw mode, redrawing the frame on every keystroke so the typed text + appears INSIDE the box (echo is off). Backspace edits; Enter accepts; Esc / Ctrl-C / EOF + cancel (an arrow escape-sequence mid-type is swallowed, not a cancel). A ``slots`` field + accepts digits only, capped at ``slots``.""" + import select as _sel + + buf = "" + self._draw(active_value=buf, active_slots=slots, helper=helper, error=error) + while True: + try: + ch = os.read(self._fd, 1) + except KeyboardInterrupt: + raise LoginCancelled() + if not ch: + raise LoginCancelled() + if ch in (b"\r", b"\n"): + return buf + if ch == b"\x03": # Ctrl-C as a byte (if signals were off) + raise LoginCancelled() + if ch == b"\x1b": # Esc, or the start of an arrow escape sequence + r, _, _ = _sel.select([self._fd], [], [], 0.0008) + if r: + os.read(self._fd, 2) # swallow the arrow/sequence — don't cancel mid-type + continue + raise LoginCancelled() + if ch in (b"\x7f", b"\x08"): # backspace / delete + buf = buf[:-1] + self._draw(active_value=buf, active_slots=slots, helper=helper, error=None) + continue + try: + c = ch.decode("utf-8") + except UnicodeDecodeError: + continue + if not c.isprintable(): + continue + if slots and (not c.isdigit() or len(buf) >= slots): + continue # the code field is digits-only, capped + buf += c + self._draw(active_value=buf, active_slots=slots, helper=helper, error=None) + + def text_step(self, label: str, *, helper=None, slots=None, validate=None, + error_msg=None, hidden_value: bool = False, collapse: bool = True, + initial_error=None) -> str: + """Run one bright ``❯ {label}`` input step; on a failed ``validate`` show the error sub-line + and re-prompt. On accept it collapses to a dim ``✓ {label} {value}`` line (the value is + omitted when ``hidden_value``). With ``collapse=False`` it returns the value WITHOUT adding + the ✓ line (the caller verifies it over the network first, then calls ``note``); ``initial_error`` + seeds the error sub-line on the first draw (a re-prompt after a wrong code).""" + self.active = label + error = initial_error + while True: + buf = self._read_line(helper=helper, slots=slots, error=error).strip() + if validate is None or validate(buf): + self.active = None + if collapse: + self.done.append((label, "" if hidden_value else buf)) + self._draw() + return buf + error = error_msg or "that doesn't look right" + + def note(self, label: str, value=None) -> None: + """Add a completed ✓ line that wasn't an input step (e.g. ``✓ code sent``).""" + self.done.append((label, value)) + self._draw() + + def working(self, text: str) -> None: + """Show a transient dim ``· {text}`` line (e.g. while a network call runs).""" + self._draw(note=text) + + def retry_text(self, message: str) -> None: + """Re-arm the code step with an error sub-line (used after a wrong code).""" + # text_step's own loop shows the error; this is for the verify-fail re-prompt path. + self._draw(error=message) + + def pick(self, slugs: Sequence[str], *, default: Optional[str] = None) -> str: + """The nested org-picker inset: arrow keys move the cursor, Enter selects (collapses to + ``✓ org {slug}``), Esc / Ctrl-C raise ``LoginCancelled``.""" + slugs = list(slugs) + idx = slugs.index(default) if default in slugs else 0 + self.active = None + self._draw(inset=output.login_inset(slugs, idx)) + while True: + try: + key = _read_key(self._fd) + except KeyboardInterrupt: + raise LoginCancelled() + if key in ("UP", "k"): + idx = (idx - 1) % len(slugs) + elif key in ("DOWN", "j"): + idx = (idx + 1) % len(slugs) + elif key == "ENTER": + chosen = slugs[idx] + self.done.append(("org", chosen)) + self._draw() + return chosen + elif key in ("ESC", "CTRL_C", "q", "EOF"): + raise LoginCancelled() + else: + continue + self._draw(inset=output.login_inset(slugs, idx)) + + def finish(self, email: str, org: Optional[str]) -> None: + """Final state: the outer border + legend flip SUCCESS green; ``● signed in`` + email + org.""" + self._draw(signed_in=(email, org)) + + def cancel(self, persisted: bool) -> None: + """Render the calm close — ``○ cancelled — not signed in`` (or ``○ signed in · pick an org …`` + when the session was already persisted).""" + self._draw(cancelled=bool(persisted)) + + def fail(self, message: str, hint: Optional[str] = None) -> None: + """Render a failure INSIDE the box (red border + ``✗ {message}`` + an optional hint) — e.g. + a wrong/expired code — instead of a separate error box below the frame.""" + self._draw(failed=(message, hint)) diff --git a/fp-cli/fp_cli/theme.py b/fp-cli/fp_cli/theme.py new file mode 100644 index 000000000..a46965764 --- /dev/null +++ b/fp-cli/fp_cli/theme.py @@ -0,0 +1,53 @@ +"""Shared brand color tokens + the permission action→color map. + +Truecolor hex; Rich downgrades to the nearest supported color automatically and drops color +entirely under ``NO_COLOR`` / non-color terminals. +""" + +from __future__ import annotations + +ACCENT = "#9d7bff" # brand mark, box borders, active org, command names in hints +TEXT = "#d8d2dd" # primary values +TEXT_DIM = "#7d7488" # ids, resource names, org names +LABEL = "#6b6478" # field labels, header cells, hints +FAINT = "#5a5266" # separators, inactive markers +THIN_RULE = "#2e2435" # the faint rule beneath a list-panel header +BAR_EMPTY = "#2a2530" # the unfilled cells of a mini-bar (e.g. the aggregate avg bar) +INSET_BG = "#231e2d" # a hair-lighter fill for a nested inset box (the login org picker) + +# Errors theme — the one per-command border deviation (the `errors` list + aggregate card). +# Always red, regardless of error count, so the two views read as one consistent family. +BORDER_ERROR = "#e2564a" # red → the errors panel border (same as ERROR; consistent, not count-dependent) +TITLE_ERROR_DIM = "#6e3530" # dim red → the errors title's non-name part + card separators +RULE_ERROR = "#3a2d2d" # dim red → the errors list header rule (vs the neutral THIN_RULE) + +# Semantic value colors — for run/job states and score thresholds. (They coincide with +# the perm risk colors below; named separately so the two uses can diverge later.) +SUCCESS = "#5dcaa5" # green → run status: done/passed +AMBER = "#ef9f27" # amber → run status: running/pending · score band .50–.80 +ERROR = "#e2564a" # red → run status: failed/error · score band < .50 +SCORE_HIGH = "#3ddbb8" # cyan-green → score band ≥ .80 (distinct from status green) +BLUE = "#6b86d8" # blue → schema uuid/timestamp type category +PINK = "#d4537e" # pink → numeric values (query run cells / scalar card) + numeric type category + +# Permission verb colors — by ACTION (the part after `:`), never by resource. +PERM_READ = "#5dcaa5" # green → read +PERM_WRITE = "#d4537e" # pink → add, create, write, update +PERM_ACTION = "#ef9f27" # amber → use, trigger, run, ack +PERM_DANGER = "#e2564a" # red → delete, disable, regenerate + +PERM_COLORS = { + "read": PERM_READ, + "add": PERM_WRITE, "create": PERM_WRITE, "write": PERM_WRITE, "update": PERM_WRITE, + "use": PERM_ACTION, "trigger": PERM_ACTION, "run": PERM_ACTION, "ack": PERM_ACTION, + "delete": PERM_DANGER, "disable": PERM_DANGER, "regenerate": PERM_DANGER, +} +DEFAULT_PERM_COLOR = LABEL # any unmapped action → neutral dim (never crash / guess a risk) + +# Risk rank for ordering actions within a row: read → modify → invoke → destroy. +PERM_RANK = {PERM_READ: 0, PERM_WRITE: 1, PERM_ACTION: 2, PERM_DANGER: 3} + + +def perm_color(action: str) -> str: + """The color for a permission action, or the neutral default if it's unmapped.""" + return PERM_COLORS.get(action, DEFAULT_PERM_COLOR) diff --git a/fp-cli/pyproject.toml b/fp-cli/pyproject.toml new file mode 100644 index 000000000..f8c22ab27 --- /dev/null +++ b/fp-cli/pyproject.toml @@ -0,0 +1,70 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "fp-cli" +dynamic = ["version"] +requires-python = ">=3.10" +description = "Command line client for FailproofAI Cloud — sessions, events, evaluations and audits from your terminal" +readme = "README.md" +license = { file = "LICENSE" } +authors = [{ name = "Failproof AI", email = "failproofai@exosphere.host" }] +keywords = ["agents", "observability", "ai", "monitoring", "failproofai", "cli"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] +dependencies = [ + "httpx>=0.27", + # This range spans typer's Click split: 0.26 vendored Click into `typer/_click` and + # dropped the `click` dependency, so Typer's command runner catches ITS Click's + # exceptions, not the pip `click` ones. `fp_cli/_click_compat` resolves whichever + # Click the installed typer is running, and everything raises, subclasses and inspects + # through that — which is what makes both sides of the split work. Import Click from + # there and nowhere else; `tests/test_click_compat.py` enforces it, because every way + # this breaks is silent (typed errors collapse to exit 1 with an empty stderr, and the + # telemetry flag catalog empties). Suite verified against 0.25.1 and 0.27.0. + "typer>=0.12,<0.28", + # `click` is what `_click_compat` falls back to when typer is < 0.26 (0.26+ brings its + # own), and `pygments` is imported directly. Both are declared rather than relied on + # transitively: modern `typer` no longer installs `click`, and `rich` could likewise + # drop `pygments`. + "click>=8.1", + "rich>=13", + "pygments>=2.13", + "posthog>=3.5,<8", +] + +# The distribution is `fp-cli`; the command is `fp`. They differ on purpose — `fp` is what +# you type, `fp-cli` is what pip resolves, and `fp` was already taken on PyPI. +[project.scripts] +fp = "fp_cli.app:main_entry" + +[project.urls] +Homepage = "https://befailproof.ai" +Documentation = "https://docs.befailproof.ai/agenteye/cli" +Source = "https://github.com/FailproofAI/failproofai" + +[project.optional-dependencies] +dev = ["pytest>=7", "respx>=0.21"] + +[tool.setuptools.dynamic] +version = { attr = "fp_cli._version.__version__" } + +[tool.setuptools.packages.find] +where = ["."] +include = ["fp_cli*"] + +[tool.setuptools.package-data] +fp_cli = ["py.typed"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/fp-cli/skill/SKILL.md b/fp-cli/skill/SKILL.md new file mode 100644 index 000000000..28138d112 --- /dev/null +++ b/fp-cli/skill/SKILL.md @@ -0,0 +1,292 @@ +--- +name: fp-cli +description: |- + The way to answer "how are my production AI agents doing?" and to run the team's agent-observability deployment — reach for it even on casual phrasing that names no tool. + + Trigger when the user wants to: + • inspect agent telemetry — did agents error/fail/go flaky; sessions, events, latency, token usage, slowest models; eval/quality scores and whether quality dropped; + • operate the deployment — ack/assign/resolve/mute/dismiss issues (alerts, reports, and audit findings) with notes; run and triage audits; see who has access and change roles (e.g. read-only); create or scope API keys (e.g. a push-only CI key); change settings; run saved or ad-hoc ClickHouse queries. + + Served by the `fp` CLI against FailproofAI Cloud. + + NOT for writing or designing an evaluator service / scoring logic (that's `agenteye-evaluator`), adding SDK/instrumentation to your app (that's the `agenteye` Python SDK), debugging the collector/daemon, or unrelated dev work (why a build/CI run failed, rotating non-FailproofAI Cloud secrets). +--- + +# FailproofAI Cloud CLI + +`fp` is a command-line client for a FailproofAI Cloud deployment. It authenticates +either as a signed-in **user** or with a scoped **API key** (§2), and every command +takes `--json`, so it's built to be driven by an agent. + +## 1. Find how to invoke it + +Resolve this once, then reuse it for every call: + +1. If `fp` is on `PATH` (`command -v fp`) → use **`fp`** (it's + installed via pipx / uv tool / pip). This is the normal case. +2. Else, if you're in (or under) a repo with an `fp-cli/` directory containing the + `fp_cli` package → run it from there with **`uv run fp`** (a local dev + build). The first run after a code change prints `Building…`/`Installed…` on + stderr — that's `uv`, not CLI output; ignore it. +3. Else the CLI isn't available here → tell the user to install it + (`pipx install fp-cli` or `uv tool install fp-cli`) and stop. Don't try to + reach the dashboard another way. + +Don't go spelunking in the CLI source tree for flags — if you're unsure of one, +run `fp <group> <cmd> --help`. The source is not the documented contract +and reading it wastes effort. + +Throughout this skill, `fp` means "whichever form you resolved." + +## 2. The contract (the CLI enforces it, work with it, don't fight it) + +- **Global options go BEFORE the command:** `fp --json events`, never + `fp events --json`. Globals are `--base-url`, `--org`, `--token`, + `--api-key`, `--json`, `--insecure`/`--secure`. After the command they're a + usage error. +- **Two ways to authenticate, and they are not interchangeable:** + + | | How you supply it | What it is | + |---|---|---| + | **Session** | `fp login` (interactive; it emails a one-time code) | a signed-in **user**, carrying that person's org memberships and permissions | + | **API key** | `--api-key <key>`, or `FP_API_KEY` in the environment | a scoped **credential**, carrying exactly the permissions it was granted | + + A key is what you want in CI or any other non-interactive context: no browser, no + emailed code, nothing to expire mid-run. + + **Credential precedence, in full** (`resolve_auth`, `fp_cli/_context.py`). Read it + as a ladder — the first rung that applies wins, and an explicit flag outranks + *every* environment variable, not just its own: + + 0. `--api-key` **and** `--token` together → usage error, exit 2. A silent guess + about which you meant is the one outcome worth refusing. + 1. `--api-key <key>` → key mode + 2. `--token <tok>` → session mode. **This beats an ambient `FP_API_KEY`** — the + flag is checked before the environment value, so "`FP_API_KEY` wins" is only + true between the two env vars. + 3. `FP_API_KEY` → key mode + 4. `FP_TOKEN` → session mode + 5. the saved session from `fp login` → session mode + + The rung that catches people is 2: exporting `FP_API_KEY` in CI and *also* + passing `--token` runs as that user's saved session, with their org memberships, + rather than under the scoped key you meant to audit. + + **A key is never written to the CLI's saved config** — pass it every time, from + the environment. `--api-key ""` means "no override" and does **not** fall back to + a saved session (Click treats an empty env var as unset, so it falls to rung 4). + +- **Some commands need a signed-in user.** `login`, `logout`, `orgs *` and the + whole `agent` group refuse a key with a usage error (**exit 2**) and make **no + network call at all** — there is no user to sign in, no saved active org to + switch, and no private assistant thread to own. `keys update` is the near miss + with a different signature: it *does* reach the server and comes back **exit + 5**, because re-scoping a key needs a permission that can never be granted to a + key. Either way the key is not the problem to fix — plan around them rather than + retrying or hunting for a flag. +- **Default to `--json` and parse it.** It prints clean JSON to stdout and + nothing else. The plain output is a boxed Rich UI meant for human eyes — it + burns context with box-drawing characters and is awkward to parse. Use the + rendered output only when the user explicitly wants to *look* at something. +- **Data → stdout, status/errors/prompts → stderr.** So a `--json` stdout + capture is pure JSON even when a status line is shown. +- **Branch on exit codes — don't scrape error text:** + + | code | meaning | what to do | + |---|---|---| + | 0 | ok | parse stdout | + | 1 | unexpected / server error | report it to the user | + | 2 | usage error (bad flags/args) | fix the command and retry | + | 3 | can't reach the dashboard | check base-url / connectivity | + | 4 | no usable credential — not signed in, session expired, **or the API key was rejected** | session: user must run `fp login`. Key: it's missing, mistyped, disabled, or belongs to another deployment — don't retry, and don't fall back to a session | + | 5 | authenticated but missing permission | message names the exact permission | + | 6 | resource not found | the named resource doesn't exist | + +## 3. First call: confirm you're connected + +Before real work, run `fp --json whoami` and react to the exit code: + +- **exit 4** → no usable credential. If the user is working from a session, tell + them to run `fp login` (it emails a one-time code and prompts + interactively — you can't complete it for them, and don't fabricate a token). + If a key was supplied, the key itself was rejected — say so and stop; logging in + is not the fix, and silently switching to a session would run the command as a + different identity than the user asked for. +- **base-url** → the CLI defaults to the hosted product, + `https://app.befailproof.ai`, so a plain `fp login` works out of the box. + Only pass `--base-url <url>` (or set `FP_DASHBOARD_URL`) for a self-hosted + or dev deployment — a local dev stack is usually `http://localhost:3000`. A + scheme-less URL is rejected as a usage error (exit 2). +- **exit 0** → `whoami` returns the active org slug and your permissions; trust + that for the org name and to know what you're allowed to do before attempting a + gated command (don't assume a particular org slug — read it from `whoami`). +- **In key mode, `whoami` answers a different question.** It still exits 0 — + `whoami` never errors — but it reports *how* you are authenticated rather than + *who* you are: there is no signed-in user, so it says so and names the auth mode + and the org it will act on. Read the auth mode; don't read "no user" as "not + authenticated" and don't try to log in on the strength of it. Since it isn't a + permission check either, let your first real read (`fp --json list envs`) + be what confirms the key works. + +**Multi-tenant:** a user can belong to several orgs; the active one is chosen at +login. Override for a single command with the global `--org <slug>` +(`fp --org acme sessions`); change the saved default with +`fp orgs switch <slug>`. + +> ⚠️ **With a key, name the org explicitly.** A key bound to one organization only +> ever acts on that one. But a key that is **not** bound to a single organization +> has nothing to fall back on — key mode never reads a saved active org — so the +> deployment resolves it to its own default, and you get **that** org's data: no +> error, no warning, results that look perfectly valid. If you cannot tell which +> kind of key you hold, pass `--org <slug>` (or set `FP_ORG`) on every +> command. Naming the org the key already belongs to is a no-op, and naming the +> wrong one fails loudly instead of quietly — both better than guessing. + +## 4. Mutations: confirm with the user FIRST + +The CLI normally prompts "are you sure?" before a destructive action — **but it +auto-skips that prompt whenever it isn't attached to a terminal, which is +exactly how you run it. `--json` skips it too.** So the safety prompt will not +fire for you. + +Therefore: **before running any command that changes state, tell the user +plainly what will change (which resource, what value) and get an explicit OK.** +Then run it. (When the user's request *is* the instruction to act — "create a +key called X" — state the exact command you'll run and proceed; when it's vague +or wide-blast — delete, disable a user, rotate a key, resolve an incident — +stop and confirm.) + +If a create fails because the name already exists (exit 2), **report that and +ask** — don't rename-and-retry or rotate/regenerate the existing one. A +`keys regenerate` you didn't intend breaks whatever already uses that key. + +State-changing commands: `keys create/update/disable/regenerate`, +`users create/update/disable/enable`, `settings set`, +`alerts create/update/delete/test`, the writing `issues` subcommands +(`ack/assign/resolve/comment-add/comment-delete/subscribe/unsubscribe/open`), +`audits create/edit/delete/run` and the finding-triage verbs +(`ack/mute/dismiss/resolve/reopen/assign`), +`query create/update/delete`, `agent rename/delete`, and `orgs switch`. +Read-only commands (§5 "Observe") never need this. + +## 5. Command map + +Pick the right group; full flags are in `references/commands.md` — read it when +you need a flag you don't already know. + +**Observe (read-only):** +- `events` — event log (light/payload-free responses by default; `--search` still scans payload server-side; `--full` or `--fields payload` returns the raw payload — keep bounded to a `--session-id`). `--session-id --event-type --env --agent-id --since --search --full --all` +- `sessions` — agent runs (time/env/agent/session/status), no scores. +- `evals` — evaluation results + scores; `--aggregate` for a health rollup; `--score key:min..max`. +- `errors` — errored events; `--aggregate` for count / sessions / agents / last-seen. +- `usage` — current org usage for its fixed 30-day metering window; needs `usage:read`. +- `list <kind>` — **discover valid filter values first**: `envs agents event_types score_filters models hooks tools error_types`. + +**Manage (permission-gated, mutations):** +- `keys list|show|create|update|disable|regenerate` — API keys; secret shown once. +- `users list|show|create|update|disable|enable` — referenced by **email**. +- `settings list|schema|set` — fixed registry; `schema` shows what each key accepts. +- `alerts list|show|create|update|delete|test` — referenced by **name**. +- `issues list|count|show|ack|assign|resolve|comment-add|comment-list|comment-delete|subscribe|subscribers|unsubscribe|open` — by id (short ids accepted). **One board for everything needing attention**: alert breaches, hand-raised issues, and audit findings, told apart by a `source` of `alert` / `manual` / `audit`. (This group was called `incidents` before; the old name is gone.) +- `audits list|show|create|edit|delete|run|runs` — scheduled sweeps, referenced by **name**; `audits findings|finding` + the triage verbs `ack|mute|dismiss|resolve|reopen|assign` act on a finding **id**. `audits run <name>` only *queues* a run (poll `audits runs <name>` for completion). See §8. + +**Analytics & assistant:** +- `query list|show|create|update|delete|run|schema` — saved ClickHouse SQL + ad-hoc runner (`query run <name>` or `query run --sql "…"`); `query schema [table]` for table layout. +- `agent health|models|chats|ask|show|rename|delete` — built-in assistant; `agent ask "…"` starts a chat, `--chat <short-id>` continues one. + +**Identity:** `login`, `logout`, `whoami`, `orgs {list,switch,current,perms}`, `version`, `help`. +All of `login` / `logout` / `orgs` — like the whole `agent` group — are **session-only**: +with a key they exit 2 without calling anything (§2). `keys update` needs a user too, but +fails as exit 5. `whoami`, `version` and `help` work either way. + +## 6. Translating plain-English requests + +Users speak in outcomes, not commands ("is anything broken?", "give CI a key", +"who has access?"). Map intent → command; when a value is fuzzy, run a discovery +command (`list <kind>`, `whoami`, a `list` subcommand) before committing. + +| The user says… | Reach for | +|---|---| +| "is anything broken / failing today?", "any errors?" | `errors --since 24h --aggregate`, then `errors --since 24h --all --limit 1000` to break down | +| "why did that run fail?", "what happened in session X?" | `events --session-id X --all --limit 1000` (and `errors --session-id X`) | +| "how are my agents doing?", "show recent runs" | `sessions --since 24h` (add `--status error` for just failures) | +| "are the evals / quality scores ok?", "did quality drop?" | `evals --aggregate`; drill with `evals --score <key>:..0.5` | +| "how many events / how much traffic last week?" | `query schema` then `query run --sql "SELECT count() FROM events WHERE ts >= now() - INTERVAL 7 DAY"` | +| "what has this org used this metering window?" | `usage` (or `--json usage` for the complete response) | +| "is anything on fire?", "any alerts firing / open issues?" | `alerts list` + `issues list` (and `issues count`) | +| "ack / look at / resolve that issue" | `issues list` → `issues show <id>` → **confirm** → `issues ack`/`resolve <id>` | +| "run an audit", "what did the audit find?", "any findings to triage?" | `audits list` → `audits run <name>` (queues) → `audits runs <name>` (wait for `succeeded`) → `audits findings --audit <name>`; triage with `audits resolve/mute/dismiss <id>` — **confirm first** | +| "give CI / this service an API key" | `keys create <name> --add events:add` (scope to what they describe) — **state it, then create**; capture the one-time secret | +| "who has access?", "add / remove a teammate", "make them read-only" | `users list` / `users show <email>` / `users create`/`update`/`disable` | +| "change a setting", "what can I configure?" | `settings schema` (what's tunable) then `settings set <key> --value …` — **confirm first** | +| "what models can the assistant use?", "ask the assistant …" | `agent models`; `agent ask "…"` | +| "what can I query?", "run this SQL" | `query schema` / `query run --sql "…"` (or a saved `query run <name>`) | +| "what am I allowed to do?", "which org am I in?" | `whoami`, `orgs current`, `orgs perms` | + +If the ask is ambiguous about scope (which org, which agent, read vs. change), +resolve it with a discovery command or a quick clarifying question rather than +guessing. + +## 7. How to actually use it (recipes) + +Discover → filter → read JSON → answer in prose: + +```bash +fp --json list agents # find valid agent ids +fp --json errors --since 24h --aggregate # how bad is it right now? (full-window totals) +fp --json errors --since 24h --all --limit 1000 | jq '.errors[] | {session_id, error_type}' +fp --json sessions --status error --since 7d --all --limit 1000 # which runs failed +fp --json events --session-id run-001 --all --limit 1000 # a run's timeline (light: summaries, no payload) +fp --json events --full --session-id run-001 --all | jq '.events[].payload' # that run's RAW payloads (--full, bounded) +``` + +- **Raw `payload` is opt-in** — `events`/`errors` responses are payload-free by default; add + `--full` (or `--fields payload`) to get it, and **always bound it to a `--session-id`** + (the full feed is slow/OOM-prone at scale). For one event or a precise slice, read the + column directly: `fp --json query run --sql "SELECT payload FROM events WHERE id = <id>"` + (or `WHERE session_id = '<id>'`). See `references/commands.md` → "Getting the raw payload". + +- **`list <kind>` before filtering** — don't guess an env or agent id; the + discovery command tells you exactly what exists. +- **`--since`** takes `24h` / `7d` / etc. +- **`--all` is bounded by `--limit`, which defaults to 50.** So a bare + `errors --since 24h --all` silently returns only the first 50 rows (with + `next_cursor: null`, looking complete). For a real sweep pass a high explicit + limit: **`--all --limit 1000`** (or higher). When you only need the totals, use + `--aggregate` — it covers the whole window regardless of row caps, so it's the + reliable cross-check that you pulled everything. +- **Triage flow:** `issues list` → `issues show <id>` (read the activity + log) → confirm with the user → `issues ack <id>` or `resolve <id>`. +- **Investigate a regression:** `evals --aggregate` to see which score dropped → + `evals --score helpfulness:..0.5` to list the bad runs → `events --session-id <id>` + to see what happened inside one. + +When you've pulled what you need, answer the user in prose or a small table — +don't paste raw JSON back unless they asked for it. + +## 8. Audits — the async sweep, and how findings become issues + +An **audit** is a scheduled sweep that analyses recent agent behaviour (errors, +runaway tool loops, leaked secrets, low eval scores, …) and emits **findings**. +Two things about the flow matter when driving it from the CLI: + +- **`audits run <name>` is asynchronous — it only *queues*.** A `{"queued": true}` + does NOT mean the run finished (the analysis can take minutes). Poll + `audits runs <name>` until the newest row reads `succeeded` (or `failed`) before + reading findings — don't assume results are ready on the call that queued them. + A disabled audit, or one already mid-run, refuses to queue (exit 1). +- **Findings ARE issues — it's one bucket.** Every finding graduates to an issue + (`source = audit`) and carries its full content there, so the same problem shows + up under both `audits findings` and `issues list`. Triage is **globally + consistent in both directions**: `audits resolve <finding-id>` closes the linked + issue, and `issues resolve <issue-id>` on an audit issue resolves the finding — + either surface works, they never disagree. Triage a finding with + `audits ack|mute|dismiss|resolve|reopen <id>` (durable **mute/dismiss** suppress + the pattern org-wide by fingerprint; **resolve** leaves no suppression, so a true + recurrence reopens as new). Reads need `audits:read`, every mutation + `audits:write` (note: triaging a finding needs `audits:write`, not an `issues:*` + permission — the audit is the system of record and the issue follows it). + +Typical end-to-end: `audits list` → `audits run <name>` → poll `audits runs <name>` +→ `audits findings --audit <name>` (highest priority first) → `audits finding <id>` +for the full write-up → **confirm with the user** → `audits resolve <id>`. diff --git a/fp-cli/skill/agents/openai.yaml b/fp-cli/skill/agents/openai.yaml new file mode 100644 index 000000000..e461695ec --- /dev/null +++ b/fp-cli/skill/agents/openai.yaml @@ -0,0 +1,8 @@ +# Codex skill configuration (optional). See https://developers.openai.com/codex/skills +# +# Codex reads SKILL.md's `name`/`description` the same way Claude Code does. +# This file only tunes Codex-specific behavior. + +# Let Codex auto-select this skill when a task matches the description +# (set to false to require explicit `$fp-cli` invocation). +allow_implicit_invocation: true diff --git a/fp-cli/skill/references/commands.md b/fp-cli/skill/references/commands.md new file mode 100644 index 000000000..d51b66485 --- /dev/null +++ b/fp-cli/skill/references/commands.md @@ -0,0 +1,254 @@ +# FailproofAI Cloud CLI — full command reference + +Flag-level detail for every group. Read the section you need; the SKILL.md body +already has the workflow and the contract. Remember: **globals before the +command**, **`--json` to parse**, **branch on exit codes**. + +## Contents +- [Global options](#global-options) +- [Shared input conventions](#shared-input-conventions) +- [Identity: login / logout / whoami / orgs](#identity) +- [Observe: events / sessions / evals / errors / usage / list](#observe) +- [keys](#keys) +- [users](#users) +- [settings](#settings) +- [alerts](#alerts) +- [audits](#audits) +- [issues](#issues) +- [query](#query) +- [agent](#agent) + +## Global options +Set on the CLI, **before** the subcommand. Precedence: flag > env var > config file (`~/.failproofai/fpcli/cli-auth.json`, mode 0600). + +| Flag | Env var | Meaning | +|---|---|---| +| `--base-url <url>` | `FP_DASHBOARD_URL` | Dashboard URL. Defaults to `https://app.befailproof.ai` (the hosted product); override for self-hosted/dev. Must start with `http://`/`https://`. | +| `--org <slug>` | `FP_ORG` | Active org for this command (multi-tenant override). | +| `--token <t>` | `FP_TOKEN` | Session token (normally from config after `login`). | +| `--api-key <k>` | `FP_API_KEY` | Scoped API key — authenticate as a credential instead of as a signed-in user. Never saved to the config file. | +| `--json` | `FP_JSON` | Machine-readable JSON to stdout, nothing else. | +| `--insecure` / `--secure` | `FP_INSECURE` | Skip / require TLS verification (for self-signed dev certs; saved at login). | +| `--version` | | Print version (also `fp version`). | + +Config dir resolves as `FP_HOME` > `$FAILPROOFAI_HOME/fpcli` > `~/.failproofai/fpcli`; `FP_HOME` is used as-is, `FAILPROOFAI_HOME` gets `fpcli/` appended. Telemetry is currently disabled globally while its send path is made fully non-blocking. `FP_ANALYTICS_DISABLED=1` and `DO_NOT_TRACK=1` remain supported opt-out controls for when telemetry is re-enabled. + +### Session or API key +Exactly one credential is in play per invocation, chosen in this order: + +| You supply | Result | +|---|---| +| `--api-key` **and** `--token` | usage error, exit 2 — it never guesses | +| `--api-key` | key mode | +| `--token` | session mode | +| `FP_API_KEY` | key mode — **wins over `FP_TOKEN`** | +| `FP_TOKEN` | session mode | +| a saved session (from `login`) | session mode | +| nothing | exit 4 | + +- **The key is never persisted.** A session token is saved by `login` and expires on its own; an API key is valid until someone revokes it, so the CLI keeps it out of the config file entirely. Supply it per invocation, normally via `FP_API_KEY`. +- **`--api-key ""` means "no override", not "fall back".** Mode stays *key*, the credential is empty, and the command exits 4 — it will not quietly use a saved session. (Same rule as `--token ""`.) An empty *environment variable* is a different story: `FP_API_KEY=""` reads as unset and falls through to the next credential, so an unset CI variable can silently run as whichever human is logged in on that machine. Pass the flag if you need the strict behaviour. +- **A rejected key is exit 4**, same as an expired session. Report it; don't retry and don't switch credentials. +- **Session-only commands:** `login`, `logout`, `orgs *`, `agent *`. In key mode each exits **2** *before making any request* — there's no user to sign in or switch orgs for, and no private assistant thread to own. `keys update` also requires a signed-in user, but it fails later and differently: the request goes out and comes back **exit 5**, because re-scoping keys needs a permission that cannot be granted to a key. +- **Name the org when using a key.** Key mode sends the org only when you supply it (`--org <slug>` / `FP_ORG`) — it never reuses the saved active org from `login`. A key bound to one organization only ever acts on that one; a key that is not bound to a single organization falls back to the deployment's default, and you get plausible-looking data from the wrong tenant with no error and no warning. Naming the key's own org is a no-op; naming a different one is rejected. Both beat guessing. + +## Shared input conventions +- **`--json`** on any command → pure JSON on stdout (no Rich chrome). Mutations under `--json` auto-skip their confirm prompt. +- **`--yes` / `-y`** explicitly skips a confirm prompt. (Confirms are also auto-skipped on a non-TTY — i.e. whenever Claude runs it — so always confirm with the user yourself first.) +- **`--all` + `--limit`**: `--limit` (`-n`) defaults to **50**; `--all` auto-paginates (client chunks of 200) **up to `--limit`**, NOT without bound. So a bare `--all` still stops at 50 rows. For a full sweep on `events/sessions/evals/errors`, pass a high explicit cap: **`--all --limit 1000`** (or higher). To just get window totals, use `--aggregate` (covers the whole window regardless of row caps). +- **`--fields a,b,c`** projects only those keys (where supported: sessions/evals, keys, query list). +- **Policy source** (`policies publish` / `policies test`) comes from a path, `@path`, a pipe, `-`, or an interactive paste. A path that is not readable UTF-8 text — a binary file pointed at by mistake — is refused by name (**exit 2**), as is a missing path; neither reaches the server. +- **`--since <window>`** relative window — one of `15m`, `1h`, `6h`, `24h`, `7d`, `all` (any other value is a usage error, exit 2). `--from`/`--to` take ISO timestamps **with `T` and a timezone** (e.g. `2026-06-01T00:00:00Z`) — space-separated or tz-less is a usage error (exit 2). +- **`--file payload.json`** (or `--file -` for stdin) supplies a full JSON request body on `alerts`, `settings set`, and `users create/update` — mutually exclusive with the discrete flags. Saved-query SQL uses `--sql @file.sql`. +- **Multi-value filters** are CSV → `IN (...)` (union within a filter, AND across filters): `--event-type tool_use,tool_result`. `--search` is repeated/OR (matches ANY term), payload-only. + +## Identity + +### login / logout / whoami +- `fp login [--email you@x.com] [--org <slug>]` — emails a one-time code; on a real TTY it's a single interactive box, else a plain prompt. Saves the session to `~/.failproofai/fpcli/cli-auth.json` (was `~/.fp/cli.json`; a session at the old path is adopted automatically on the next command, so an upgrade signs nobody out). **You cannot complete this for the user** (it needs the emailed code). `--org` picks the tenant at login. **Session-only** — exit 2 under a key. +- `fp logout` — clears the saved session. **Session-only** — exit 2 under a key (a key cannot be "logged out"; revoke it instead). +- `fp whoami` — active org + your permissions. Run this first; exit 4 = no usable credential. + **Under a key it answers a different question and still exits 0:** it reports that there is no signed-in user, names the auth mode, and gives the org it will act on. So branch on the auth mode, not on the absence of a user identity — and note that it does not prove the key is accepted or check any permission. Let the first real read do that. + +### orgs +**Session-only, the whole group** — each exits 2 under a key, with no request made. Use `--org <slug>` per command instead. +- `orgs list` — your orgs + role in each (active marked). +- `orgs switch [<slug>]` — change the saved active org; omit slug to pick from a list (TTY only). **State change** (mild) — affects later commands. +- `orgs current` — identity card for the active org. +- `orgs perms` — your permissions in the active org, grouped by resource. + +## Observe +All read-only; never need confirmation. + +### events +`fp events [filters] [--all]` — event log, newest first. **Default is the light, +payload-free feed**: rows carry `summary, is_error, error_type, output_tokens, +context_window, context_fill` (a server-computed `summary`, no raw payload). +`--session-id`, `--all`, and structured filters stay on this fast path. `--search` is the +exception: responses remain payload-free, but the server must scan `payload` to match the +free-text term, so broad searches can still be expensive. To get the raw `payload`, opt +into the **full feed** with `--full` (or `--fields payload`) — that read is slow at scale, +so keep it bounded (pair `--full` with one `--session-id`). +e.g. `fp --json events --full --session-id run-1 --all | jq '.events[].payload'`. +Filters: `--session-id <id>` `--agent-id <id>` `--event-type <csv>` `--env <csv>` `--since <window>` / `--from`/`--to` `--search <term>` (repeatable, payload OR-match). + +#### Getting the raw payload +The default `events`/`errors` reads are payload-free. Only `--full` (or `--fields payload`) +returns the raw `payload`, and that is the heavy feed — **always bound it** (pair with +`--session-id`); an unbounded `events --full` can time out or degrade the event store at +scale. +- **A whole session:** `fp --json events --full --session-id <SESSION_ID> --all --limit 1000 | jq '.events[].payload'` +- **A single event:** scope to its session, then pick by id — `fp --json events --full --session-id <SESSION_ID> --all | jq '.events[] | select(.id == <EVENT_ID>) | .payload'` +- **An error's payload:** two steps — `fp --json errors --error-type <T> --since 24h` (gives the error's `id` and `session_id`; `errors` is light-only, no payload), then `fp --json events --full --session-id <SESSION_ID> --all | jq '.events[] | select(.id == <ERROR_EVENT_ID>) | .payload'` +- **Precise / by id (avoids the heavy list query):** `fp --json query run --sql "SELECT id, event_type, payload FROM events WHERE session_id = '<SESSION_ID>' ORDER BY ts"` — or `WHERE id = <EVENT_ID>`. Reads `payload` directly via the read-only SQL runner; a bounded `WHERE` is fast. + +### sessions +`fp sessions [filters] [--all]` — agent runs: time/env/agent/session/status (no scores). Filters: `--session-id --agent-id --env --status <error|...> --since`. JSON rows still carry `scores`. + +### evals +`fp evals [filters] [--score key:min..max] [--scores-full] [--all]` — evaluation results + scores. +`fp evals --aggregate [--since 7d]` — rollup: `{total, status_counts, score_stats, timeline}` (status mix + per-metric score stats). `--score helpfulness:..0.5` = max 0.5; `helpfulness:0.8..` = min 0.8; `helpfulness:0.5..0.9` = range. + +### errors +`fp errors [filters] [--all]` — errored events (time/event/env/agent/session/summary), from the light payload-free feed; the `summary` is the server-computed field, and `--json` rows carry no payload. For a run's raw payload use `fp events --full --session-id <id>`. Filters incl. `--error-type <csv>`. +`fp errors --aggregate [--since 7d]` — `{total, sessions, agents, last_ts, bins}`. + +### usage +`fp usage` — the active org's current fixed 30-day metering window, grouped for human +reading. Needs `usage:read`. `fp --json usage` returns the dashboard contract unchanged: +`org_id`, `billing_anchor`, `window`, `usage`, `calculated_at`, and `stale_after`. It has no +filters or subcommands and is read-only; limits and enforcement are not part of this command. + +### list +`fp list <kind>` — discover valid filter values. Kinds: `envs agents event_types score_filters models hooks tools error_types`. JSON `{kind, values}`. Run this before filtering by a value you're unsure of. + +## keys +API keys; the secret is shown **once** on create/regenerate (capture it then). Referenced by **name**. +- `keys list [--show-id] [--fields ...]` — active keys first, then revoked. +- `keys show <name>` +- `keys create <name> [--permission-set <set>] [--add <tok>] [--remove <tok>]` — permissions work **exactly like `users create`**: optionally seed from a role with `--permission-set` (`read-only`/`standard`/`admin` or a custom org set), then fine-tune with `--add`/`--remove`. Effective grants = `(set ∪ added) − removed`. For a narrowly-scoped key (the common case) just use `--add` with no set: `keys create ci-pipeline --add events:add`. Secret → stdout when piped. (There is **no** positional `PERMISSIONS` arg and **no** `-p` flag — those forms error.) +- `keys update <name> [--permission-set <set>] [--add <tok>] [--remove <tok>]` — incremental on the key's CURRENT grants (merges --add/--remove), unless `--permission-set` is given (which reseeds, then applies --add/--remove). `--yes`/`-y` to skip confirm. **Needs a signed-in user** — under a key it reaches the server and returns **exit 5**, because `keys:update` is never assignable to a key. Every other `keys` subcommand works under a key that holds the matching grant. +- `keys disable <name>` — revoke. +- `keys regenerate <name>` — rotate secret (old one dies). + +Permission token format (for `--add`/`--remove`): `slug:action` flat, or `slug:action.action` to expand several actions on one resource (e.g. `events:read.add` → `events:read`, `events:add`). Several via comma, repeated flag, or a quoted group: `--add events:read,keys:read` · `--add a --add b` · `--add "a b"`. Human-only perms (`keys:update`) can't be granted to a key. Unknown/malformed → exit 2. + +## users +Referenced by **email** (UUID id also accepted). +- `users list [--show-id] [--active-only]` — `[lock] email · access · perms · joined · status`. +- `users show <email>` — identity + all grants. +- `users create [EMAIL] [--permission-set <set>] [--add tok] [--remove tok]` — `--permission-set` one of the builtin sets (`admin`/`standard`/`read-only`) or a custom set name (client-validated; unknown → exit 2). `--add`/`--remove` take compact permission tokens. +- `users update <email>` — assign a set, or incrementally `--add`/`--remove`. Predicts the resulting grants and confirms. +- `users disable <email>` / `users enable <email>` — disable has protected/self guards. + +**Multi-token `--add`:** Click options aren't variadic — `--add a b` breaks. Use `--add a,b` (comma), `--add a --add b` (repeat), or `--add "a b"` (quoted). + +## settings +A fixed registry — you read/inspect/change existing keys, you cannot create new ones. +- `settings list` — `key · value · type · updated` (secrets masked). +- `settings schema` — `key · type · accepts · description` (what each key accepts). +- `settings set <key> (--value V | --json-value JSON | --file f)` — exactly one value source. Unknown key → exit 6. No-op if unchanged. Server validation errors surface as `✗ <message>` (e.g. range bounds). Some keys are sensitive (signing secrets, sign-in allowlist) — confirm carefully. + - `allowed_sign_ins` restricts which of the organization's members may sign in; it does not grant access to anyone else. An **empty list means no restriction** (every member can sign in), so clearing it widens access rather than removing it. A non-empty list admits only matching addresses and locks out every other member. Entries are exact addresses or `*@domain.tld`; a bare `*` is rejected — use an empty list. Saving a list that does not include your own address is refused, because you could not sign in again. + +## audits +Scheduled sweeps over agent activity, and the **findings** they produce. Audits are referenced by **name** (UUID id also accepted); findings by **id** (short ids shown in the table, `--show-id` for the full ones). +- `audits list [--enabled-only] [--show-id]` — `created · name · every · findings · status · last run`; disabled audits are dimmed and the footer carries the on/off split plus the open-finding total. +- `audits show <name>` — identity + `schedule` / `scope` / `analysis` / `channels` cards. The creator and the raw `scope`/`channels` blobs live here and in `--json`, not in the list. +- `audits create <name> [--file f] [--description ...] [--enabled|--disabled] [--schedule-interval-secs N] [--schedule-anchor ISO8601] [--window-mode fixed|since_last] [--lookback-window-secs N] [--scope JSON] [--ignore-error-type <csv>] [--llm|--no-llm] [--top-k N] [--sensitivity low|medium|high] [--channels JSON] [--text ... | --text-file f] [--url URL]…` — everything but the name has a server default. Name collision is pre-checked (exit 2). No confirm (creating isn't destructive). `--text`/`--text-file`/`--url` attach the reference context **in the same request**, and that is the only way to be sure the first run has it: a new enabled audit is due immediately, so context set afterwards can miss it. Same caps as `context-set` (8192 chars, 5 URLs, public `https://`); a URL the guard refuses fails the whole create, so no half-made audit is left behind. `--json` returns `{id, created_at, sources}`. +- `audits edit <name> [--name ...] [same flags as create] [--yes]` — the server replaces the whole definition, so a flag-only edit re-sends the current audit with your change applied (needs read **and** write). Rename onto an existing name → exit 2. Confirms first. +- `audits delete <name> [--yes]` — amber preview (naming the findings and run history that go with it) + confirm. +- `audits run <name>` — queue a run **now**, ahead of schedule. Success means queued, not finished; a disabled audit or one already mid-run is refused with the server's explanation (exit 1). JSON `{"queued": true}`. +- `audits context-show <name>` — the operator brief plus every reference URL with its fetch state (chars stored, whether truncated, how many secret-shaped values were masked, whether the page carries phrases that read as instructions to an AI). +- `audits context-set <name> [--text ... | --text-file f] [--url URL]… [--clear-urls]` — the brief and the URL list are independent, and **whatever you omit is left alone**: `--text` alone keeps the current URLs, `--url` alone keeps the current brief. `--url` replaces the whole list. Removal is always explicit — `--text ""` clears the brief, `--clear-urls` drops every URL and its stored snapshot; passing both `--url` and `--clear-urls` is a usage error (exit 2). At least one of the four is required. Max 8192 chars and 5 URLs, public `https://` only — private, loopback and cloud-metadata addresses are refused at save with the reason (exit 1). Pages are fetched in the background, so this returns before the snapshot exists. +- `audits context-refresh <name>` — re-fetch every non-blocked reference URL now. Snapshots refresh weekly on their own; URLs the guard refused are never retried, because nothing about them can change until the URL does. + +Context is a **separate sub-resource on purpose**: `audits edit` read-merges the definition from a fixed field list, so a brief carried in that body would be silently wiped by an unrelated flag-only edit. Writing it through `context-set` makes that impossible. Creation is the one exception, and only because of a race: the audit's first run is queued the instant its row is written, so context that follows in a second request can arrive after that run started. `audits create` therefore sends it inline and the server commits both together — `audits edit` still refuses it (exit 1), which is what keeps the read-merge harmless. `--file` bodies are filtered before they are sent, so `audits show --json > f && audits edit <name> --file f` round-trips cleanly even though `show` emits server-owned fields; sending `additional_context` or `reference_urls` to the definition endpoint by hand is refused rather than ignored. +- `audits runs <name> [--limit N] [--show-id]` — run history, newest first: `started · status · trigger · findings · new · took`. A failed run's `error` and each run's `stats`/`report` are in `--json` only. +- `audits findings [--audit <name>] [--run-id <id>] [--status <csv>] [--limit N] [--offset N] [--show-id]` — the triage queue, highest priority first: `id · title · severity · status · kind · seen · last`. With no `--status` you get the live set (open + recurring); valid statuses are `open recurring resolved dismissed muted`. `--audit` takes an audit **name**. +- `audits finding <id>` — one finding in full: identity + `analysis` (what + likely cause) + `recommendation` (do / impact / effort) + `scope` + `evidence`. Empty sections are omitted. +- `audits ack <id> [--reason ...]` — seen, stays visible, ranked lower. No confirm. +- `audits mute <id> [--reason ...] [--yes]` — stop surfacing this pattern in future runs (durable). Confirms first. +- `audits dismiss <id> [--reason ...] [--yes]` — judged not worth acting on; suppressed like mute. Confirms first. +- `audits resolve <id> [--yes]` — you fixed it. Leaves **no** suppression, so a genuine recurrence is raised as new. Confirms first. +- `audits reopen <id>` — back to `open` **and** clears any mute/dismiss suppression. The undo for the three above. +- `audits assign <id> --to <email>` — set the owner; the status is untouched. `--to` is required (exit 2 without it). + +The title column truncates to whatever width is left so the fixed columns always survive — read the full text with `audits finding <id>` or `--json`. A bad `--status`, `--window-mode`, `--sensitivity`, a non-ISO-8601 `--schedule-anchor`, or an out-of-range `--schedule-interval-secs`/`--lookback-window-secs` is rejected before any request (exit 2). `--schedule-anchor` pins the fixed UTC slot runs land on (`anchor + N * interval`), so a slow run or `audits run` can't drift the cadence; omit it on create and the server uses the next 09:00 UTC. An unknown audit name → exit 6; an unknown or malformed finding id → calm `✗ no finding …`, exit 6. Reads need `audits:read`, every mutation `audits:write`. + +**`audits run` is async — it only queues.** Success is `{"queued": true}`, not a finished run; the analysis can take minutes. Poll `audits runs <name>` until the newest row is `succeeded`/`failed` before reading `audits findings`, rather than assuming results exist on the call that queued them. + +**Findings and issues are one bucket.** Every finding graduates to an issue (`source: audit`) that stores the finding's full content, so the same problem appears under both `audits findings` and `issues list`. Triage is consistent in **both** directions and needs `audits:write` either way: `audits resolve|mute|dismiss|ack|reopen <finding-id>` mirrors onto the linked issue, and `issues resolve <issue-id>` on an audit issue mirrors back onto the finding — the two never disagree. **resolve** leaves no suppression (a genuine recurrence reopens as new); **mute/dismiss** suppress the pattern org-wide by fingerprint. + +## issues +The single board for everything needing human attention — alert breaches (`source: alert`), hand-raised issues (`manual`), and audit findings (`audit`). Referenced by id (short ids accepted, `--show-id` shows them). This group was **renamed from `incidents`**; the old name no longer exists. Reads and ack/comment need `issues:read`; opening, assigning, and subscribing others need `issues:create`; resolving needs `issues:close`. +- `issues list [--state firing|acknowledged|resolved] [--alert-id <id>] [--limit N] [--show-id]` — there is **no** `--severity` filter on this command. +- `issues count` +- `issues show <id>` — identity + comments + subscribers + **activity log** (read this before acting). An audit-born issue (`source: audit`) carries the full finding it graduated from and back-links to the audit/run. +- `issues ack <id>` · `issues assign <id> --assignee <member>` (repeatable; omit to clear all assignees; each must be an operator) · `issues resolve <id>` (calm confirm). **On an audit issue these stay in sync with the finding** — resolving the issue resolves the underlying audit finding, so it can't reappear on the next run (equivalently, triage it with `audits resolve <finding-id>`; both surfaces agree). +- `issues open --summary <text> (--title <text> | --alert-id <id>) [--title ...] [--severity ...]` — `--title` is **required** for a standalone issue (nothing to borrow a name from); with `--alert-id` it is optional and defaults to the alert's name. Missing `--title` on the standalone path → exit 2. +- `issues comment-add <id> (--body <text> | --file <path>)` — `--file -` reads stdin; exactly one of the two · `comment-list <id>` · `comment-delete <id> <comment-id>` +- `issues subscribe <id> [--email <addr>]` · `unsubscribe <id> [--email <addr>]` · `subscribers <id>` — `--email` defaults to you; naming someone else needs `issues:create` + +Malformed (non-UUID) id → calm `✗ no incident …` exit 6. Assign to a non-operator → clean 422 message. + +## query +Saved ClickHouse SQL + ad-hoc runner. Saved queries referenced by **name**. +- `query list [--fields ...] [--show-id]` — `name · description · created by · created`. +- `query show <name>` — metadata + syntax-highlighted SQL. +- `query create <name> --sql "…"|@file.sql [--description ...] [--param k=v]` — name-collision pre-checked (exit 2). +- `query update <name> [--name ...] [--sql ...] [--description ...] [--param ...]` — partial update (≥1 field). +- `query delete <name>` — amber preview + confirm. +- `query run <name> | --sql "…" [--limit N] [--all] [--param k=v]` — adaptive render: scalar / record / table. JSON = full QueryResult (never capped). Exec/permission errors → clean `✗ query failed` + exit code. +- `query schema [TABLE]` — column layout; JSON `{schema, columns:[{table,column,type,nullable}]}`. + +## agent +**Session-only, the whole group** — each subcommand exits 2 under a key, with no request made: a chat is private to the person who owns it, and a key is not a person. + +Built-in assistant. Chats referenced by a **short chat-id** (first 8 hex; prefix-resolved). +- `agent health` · `agent models` (available models for `--model`, default marked). +- `agent chats` — `chat-id · title · messages · updated`. +- `agent ask "MESSAGE" [--chat <short-id>] [--model <m>]` — starts a new chat (prints its short id) or continues `--chat`. On a TTY the answer renders as Markdown; piped/non-TTY prints the raw answer to stdout. +- `agent show <short-id>` — transcript. `agent rename <short-id> --title "…"` · `agent delete <short-id>`. +- Ambiguous prefix → exit 2; unknown chat → exit 6. + +## policies · fleet · guardrails +**Session-only, all three groups** — every subcommand exits 2 under a key, with no request made. These routes are absent from the versioned API an API key authenticates against; they are an operator surface. + +Cloud-managed enforcement, split the way the dashboard splits it: `policies` writes a version, `fleet` decides which machines run it, `guardrails` reports what it blocked. Needs `policies:read` to read, `policies:write` to change anything. + +### policies +- `policies list` — one row per published VERSION (versions are immutable and all stay addressable), newest of each policy first; the title counts distinct policies and captions the version total. `state` is active / disabled / archived. JSON `{policies:[…]}` — also every version, so deduplicate on `id` for one row per policy. +- `policies show <id>` — the NEWEST version, including the full `source`. +- `policies publish <id> [SOURCE] [--description "…"] [--no-verify]` — mints a **new version**; never edits one. SOURCE is a path, `@path`, `-`, a pipe, or omitted to paste on a TTY (Ctrl-D ends). The source is **parse-checked with node** before it is sent; nothing downstream does this, and a broken policy otherwise fails on the machine at enforcement time. `--no-verify` skips it, and a host without node publishes with a warning rather than a block. **Publishing deploys nothing** — the version is unused until `fleet deploy` puts it on a machine. +- `policies enable <id>` · `policies disable <id> [-y]` — **disable REMOVES the policy from every deployment carrying it**, reissuing each affected machine at a new generation (visible in `fleet history`). `enable` is the exact inverse — it puts the policy back into every deployment it was removed from, reissuing those machines again. Nothing needs redeploying by hand, and `machinesUpdated` in the JSON reports the count for both directions. +- `policies test [SOURCE] [--tool Bash] [--command "…"] [--file PATH] [--event PreToolUse] [--expect allow|deny|instruct]` — run a policy LOCALLY and print what it decides. Executes the real file (bare `import { deny } from "failproofai"` and all) against a synthetic context; nothing is published, nothing installed. Needs `node`. `--expect` asserts the decision and exits 1 when it differs — a correct `deny` is a PASSING test, so the decision alone never sets the exit code. JSON `{ok, decision, policies:[{name,decision,reason}], expected, met}`; `decision` is the strictest any registered policy returned. +- `policies compose "<description>" [--out FILE] [--publish ID]` — the assistant drafts policy source from plain English. Prints it and stops by default: a generated policy that deploys itself is one nobody read. `--publish` still syntax-checks first. Needs `agent:use`. The composer has a **30s server-side limit** — a long or vague description simply does not finish, and raising `--timeout` does not help because the cut is not client-side. Retry with something shorter and more specific. +- `policies delete <id> [-y]` — archives. **A machine already carrying the policy keeps enforcing it** until redeployed; `disable` is what stops enforcement everywhere. + +### fleet +- `fleet list` — `machine · label · pol · intended · applied · seen · events · state`. `intended` is the generation deployed, `applied` what the machine last collected (they differ until it polls), and `seen` how long since it last reported anything — a machine can be in sync and dead, or alive and behind, which are different problems. JSON `{machines, deployments}` with raw epoch-ms timestamps plus the computed `drifted`. +- `fleet show <machine>` — the set the machine is told to run, **and whether it has collected it**. Reads both the deployment and the machine record, so it reports `not yet collected` / `machine is on #N` / `collected` alongside who deployed it, when, and last-seen. A machine can be told to run a policy it has never picked up; the policy list alone cannot tell you which. JSON `{machine, deployment}` with raw timestamps and both label fields; `deployment: null` when nothing is deployed. +- `fleet deploy <machine> [--add REF]… [--remove ID]… [--set REF]… [--create] [-y]` + + **A deploy REPLACES the whole set.** The endpoint takes the full list and does not merge. `--add`/`--remove` are a read-modify-write: the CLI reads the current set, applies the delta, prints the complete result, writes that. `--set` replaces everything and is refused alongside `--add`/`--remove`. + + REF is `id`, `id@version`, `id:effect`, or `id@version:effect`. Effect is `enforce` (default) or `observe`. A bare `--add` of an already-deployed policy **keeps its pinned version** — pass `id@version` to move it. + + Deploying to an id that has never checked in is refused (a typo would mint a machine); `--create` allows it for pre-staging. + + **Races.** No server-side lock. The CLI records the generation it read and exits non-zero if the write does not land at exactly one higher — somebody else deployed, and a replace does not merge. Re-read with `fleet show` and retry. + + **Idempotent.** Re-running the same deploy is a no-op that exits 0 without writing — desired-state semantics, so a retrying harness succeeds rather than errors. `applied` in the JSON is the only way to tell "changed it" from "already matched"; the exit code is 0 for both. The no-op short-circuits before the write, so a reader without `policies:write` also gets 0 there — exit 0 from a no-op is not proof of write access. + + **Exit codes.** A malformed ref (`bad ref!!`, `id:banana`, empty), or `--set` combined with `--add`/`--remove`, is a usage error → **exit 2**, like every other bad flag value. A ref that parses but names something that does not exist (`--add ghost-policy`, an unpublished `@version`) is **exit 1**; an unknown machine is **exit 6**. Branch on these rather than on the message. + + JSON `{plan:{result,added,removed,changed,unchanged,noop}, deployment, applied}` — the plan is included so a harness does not recompute the diff. +- `fleet diff [machine]` — intent vs delivery per machine, with a `drifted` flag. A machine id nobody has reported under is refused (exit 6), not rendered as an empty fleet. +- `fleet history <machine>` · `fleet rollback <machine> <generation> [-y]` — rollback mints a NEW generation carrying the old set; history stays append-only. The `change` column uses the deploy plan's vocabulary: `+` added, `-` removed, `~` same policy at a different version or effect (an enforce → observe flip is a policy that stopped blocking, so it is never "no change"). +- `fleet rename <machine> "<label>"` — a human label; the id never changes. The server stores it as an override beside the machine's self-asserted label. An empty label **clears** the override (the machine falls back to its own label, else its id) and the CLI says so rather than reporting a rename to nothing. A machine that has never checked in cannot be renamed → exit 6. + +### guardrails +- `guardrails summary [--since 15m|1h|6h|24h|7d] [--machine ID]` — coverage, blocked/evaluated totals, a deny sparkline, and the per-policy table. Bare `fp guardrails` prints help, like every other group; the flags live on the subcommands. +- `guardrails timeline [--since …] [--machine ID]` — one row per time bucket: a bar scaled to the busiest bucket with the blocked share in red, plus total / denied / instructed counts. Answers *when* enforcement bit, which the summary's sparkline only sketches. +- A `(no policy)` row is **normal**: most evaluations are allows nothing objected to, and the row keeps the denominator visible. +- Coverage comes from the control plane, decision counts from reported telemetry — a machine can be deployed-to and silent, or reporting and undeployed. diff --git a/fp-cli/tests/__init__.py b/fp-cli/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fp-cli/tests/conftest.py b/fp-cli/tests/conftest.py new file mode 100644 index 000000000..a26867a37 --- /dev/null +++ b/fp-cli/tests/conftest.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import pytest +from typer.testing import CliRunner + +from fp_cli import config + +BASE_URL = "http://dash.test" + + +@pytest.fixture(autouse=True) +def pinned_terminal(monkeypatch): + """Pin the terminal rich renders against, for every test. + + Without this the suite means something different on every machine: CI has no TTY + and falls back to 80 columns, while a developer's terminal is whatever width the + window happens to be — so panels wrap in different places — and `FORCE_COLOR` + pushes ANSI escapes into output the assertions read as plain text. That is how a + green CI run coexisted with ~41 local failures. + + `COLUMNS`/`LINES` are the lever because rich reads them from ``os.environ`` at + render time (and at Console construction), so they also reach the consoles + ``output.py`` builds at import, which nothing here replaces. 140 is deliberately + wider than the boxes so nothing wraps; `TERM=dumb` costs rich its colour system, + so plain text survives even a `-s` run on a real terminal. + """ + monkeypatch.setenv("COLUMNS", "140") + monkeypatch.setenv("LINES", "50") + monkeypatch.setenv("TERM", "dumb") + # Every knob that forces colour back on regardless of the above (or off, which + # some assertions would equally depend on) — the run must not inherit any of them. + for var in ("FORCE_COLOR", "CLICOLOR_FORCE", "NO_COLOR", "TTY_COMPATIBLE"): + monkeypatch.delenv(var, raising=False) + + +@pytest.fixture +def home(tmp_path, monkeypatch): + """Isolate the CLI config dir to a temp dir, and clear env that would leak in. + + `FP_HOME` names the CLI's own directory, so the config lands directly in + `tmp_path`. `FAILPROOFAI_HOME` is cleared rather than set: it is the LOWER + precedence of the two, so leaving a developer's real one exported would not + change where this fixture points — but it would leave the suite one deleted + `setenv` away from writing into a real `~/.failproofai`. + """ + monkeypatch.setenv("FP_HOME", str(tmp_path)) + monkeypatch.delenv("FAILPROOFAI_HOME", raising=False) + for var in ( + "FP_DASHBOARD_URL", + "FP_TOKEN", + # Every env var the app callback reads has to be listed here. A developer with + # FP_API_KEY exported would otherwise run the whole auth-sensitive + # suite in API-key mode, and one with FP_ORG exported would send a tenant + # header the test never asked for — a different suite from CI's either way, + # passing or failing for a reason invisible in the diff. + "FP_API_KEY", + "FP_ORG", + "FP_JSON", + # FP_INSECURE was missing from this list for as long as it has existed (it was + # AGENTEYE_INSECURE then). A developer with it exported ran the entire suite + # with TLS verification disabled — exactly the invisible-in-the-diff divergence + # the comment above warns about, in the one variable where it is a security + # property rather than a formatting one. + "FP_INSECURE", + "NO_COLOR", + ): + monkeypatch.delenv(var, raising=False) + return tmp_path + + +@pytest.fixture +def runner(): + # Click >=8.2 dropped the mix_stderr argument (stdout/stderr are always split). + try: + return CliRunner(mix_stderr=False) + except TypeError: + return CliRunner() + + +@pytest.fixture +def logged_in(home): + """Seed a valid, unexpired session pointing at BASE_URL.""" + config.save_config( + config.CliConfig( + base_url=BASE_URL, + session_token="tok", + expires_at="2999-01-01T00:00:00Z", + email="me@test", + user_id="u1", + ) + ) + return home diff --git a/fp-cli/tests/test_alerting.py b/fp-cli/tests/test_alerting.py new file mode 100644 index 000000000..90134f248 --- /dev/null +++ b/fp-cli/tests/test_alerting.py @@ -0,0 +1,644 @@ +"""Alerting: alert definitions + incident triage.""" + +from __future__ import annotations + +import json + +import httpx +import respx + +from fp_cli import output +from fp_cli.app import app +from fp_cli.commands import _write + +BASE = "http://dash.test" + + +# --- alerts ----------------------------------------------------------------- + + +@respx.mock +def test_alerts_list_json(logged_in, runner): + respx.get(f"{BASE}/api/alerts").mock( + return_value=httpx.Response(200, json=[{"id": "a1", "name": "p95", "trigger_kind": "metric_threshold", "severity": "warning", "enabled": True, "open_incidents": 0}]) + ) + result = runner.invoke(app, ["--json", "alerts", "list"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["alerts"][0]["id"] == "a1" + + +@respx.mock +def test_alerts_list_human_boxed(logged_in, runner): + respx.get(f"{BASE}/api/alerts").mock(return_value=httpx.Response(200, json=[ + {"id": "a1", "name": "live", "trigger_kind": "custom_sql", "severity": "critical", "enabled": True, + "open_incidents": 1, "created_at": "2026-06-28T00:00:00Z", "last_attempted_at": "2026-06-28T00:00:00Z"}, + {"id": "a2", "name": "old", "trigger_kind": "metric_threshold", "severity": "warning", "enabled": False, + "open_incidents": 0, "created_at": "2026-06-20T00:00:00Z", "last_attempted_at": None}, + ])) + result = runner.invoke(app, ["alerts", "list"]) + assert result.exit_code == 0, result.output + out = result.stdout + result.stderr + assert "alerts" in out and "newest first" in out + for c in ("created", "name", "by", "trigger", "severity", "last alert"): # no status column + assert c in out + assert "status" not in out # the status column was removed + assert "never" in out # null last-alert + assert "on" in out and "off" in out # the on/off split is in the footer + assert "critical" in out and "warning" in out # footer severities + + +@respx.mock +def test_alerts_show_by_name_json(logged_in, runner): + respx.get(f"{BASE}/api/alerts").mock(return_value=httpx.Response(200, json=[_FULL_ALERT])) + result = runner.invoke(app, ["--json", "alerts", "show", "p95"]) # by NAME + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["trigger_spec"] == {"metric": "latency", "op": ">", "value": 100} + + +@respx.mock +def test_alerts_show_not_found_exits_6(logged_in, runner): + respx.get(f"{BASE}/api/alerts").mock(return_value=httpx.Response(200, json=[_FULL_ALERT])) + result = runner.invoke(app, ["alerts", "show", "ghost"]) + assert result.exit_code == 6 + + +@respx.mock +def test_alerts_show_human_cards(logged_in, runner): + respx.get(f"{BASE}/api/alerts").mock(return_value=httpx.Response(200, json=[_FULL_ALERT])) + result = runner.invoke(app, ["alerts", "show", "p95"]) + assert result.exit_code == 0, result.output + out = result.stdout + assert "p95" in out and "warning" in out and "enabled" in out + assert "trigger" in out and "fire when" in out and "latency" in out # parsed metric_threshold + assert "evaluation" in out and "checks every" in out + assert "channels" in out and "email" in out + + +@respx.mock +def test_alerts_create_from_file(logged_in, runner, tmp_path): + payload = {"name": "p95", "trigger_kind": "metric_threshold", "trigger_spec": {"metric": "latency", "op": ">", "value": 100}, "severity": "warning", "eval_interval_secs": 60} + f = tmp_path / "alert.json" + f.write_text(json.dumps(payload)) + respx.get(f"{BASE}/api/alerts").mock(return_value=httpx.Response(200, json=[])) # no name collision + route = respx.post(f"{BASE}/api/alerts").mock(return_value=httpx.Response(201, json={"id": "a9", "created_at": "t"})) + result = runner.invoke(app, ["--json", "alerts", "create", "p95", "--file", str(f)]) # name is positional + assert result.exit_code == 0, result.output + body = json.loads(route.calls.last.request.content) + assert body["name"] == "p95" + assert body["trigger_kind"] == "metric_threshold" + + +@respx.mock +def test_alerts_create_name_collision_exits_2(logged_in, runner): + respx.get(f"{BASE}/api/alerts").mock(return_value=httpx.Response(200, json=[{"id": "a1", "name": "errs"}])) + result = runner.invoke(app, ["--json", "alerts", "create", "errs", "--trigger-kind", + "metric_threshold", "--trigger-spec", '{"metric":"x","op":">","value":1}']) + assert result.exit_code == 2 + assert "already exists" in json.loads(result.stdout)["error"] + + +@respx.mock +def test_alerts_create_human_renders_card(logged_in, runner, tmp_path): + # Human mode: pre-check (no collision) → POST → re-read the canonical alert → render the card. + payload = {"name": "errs", "trigger_kind": "metric_threshold", + "trigger_spec": {"metric": "error_count", "op": ">", "value": 50, "window_secs": 900}, + "severity": "warning", "eval_interval_secs": 300} + f = tmp_path / "a.json" + f.write_text(json.dumps(payload)) + respx.get(f"{BASE}/api/alerts").mock(side_effect=[ + httpx.Response(200, json=[]), # pre-check: no collision + httpx.Response(200, json=[{"id": "a9", "name": "errs", "enabled": True, "trigger_kind": "metric_threshold", + "trigger_spec": payload["trigger_spec"], "severity": "warning", + "eval_interval_secs": 300, "min_breaches": 1, "eval_window": 1, "channels": []}]), + ]) + respx.post(f"{BASE}/api/alerts").mock(return_value=httpx.Response(201, json={"id": "a9", "created_at": "t"})) + result = runner.invoke(app, ["alerts", "create", "errs", "--file", str(f)]) # name positional + assert result.exit_code == 0, result.output + out = result.stdout + assert "alert created" in out and "errs" in out + assert "fire when error_count > 50 over 15m" in out and "channels" in out + + +@respx.mock +def test_alerts_update_rename_collision_exits_2(logged_in, runner): + respx.get(f"{BASE}/api/alerts").mock(return_value=httpx.Response(200, json=[ + _FULL_ALERT, {**_FULL_ALERT, "id": "a2", "name": "taken"}])) + result = runner.invoke(app, ["--json", "alerts", "update", "p95", "--name", "taken", "--yes"]) + assert result.exit_code == 2 + assert "already exists" in json.loads(result.stdout)["error"] + + +@respx.mock +def test_alerts_update_human_renders_card(logged_in, runner): + # First GET resolves the name (old state); the post-PUT re-fetch returns the new state. + respx.get(f"{BASE}/api/alerts").mock(side_effect=[ + httpx.Response(200, json=[_FULL_ALERT]), + httpx.Response(200, json=[{**_FULL_ALERT, "severity": "critical"}]), + ]) + respx.put(f"{BASE}/api/alerts/a1").mock(return_value=httpx.Response(200, json={"id": "a1", "updated_at": "t"})) + result = runner.invoke(app, ["alerts", "update", "p95", "--severity", "critical", "--yes"]) + assert result.exit_code == 0, result.output + out = result.stdout + assert "alert updated" in out and "p95" in out + assert "critical" in out and "trigger" in out + + +def test_alerts_create_validation_local(logged_in, runner, tmp_path): + # eval_interval_secs below 30 fails locally before any HTTP call. + payload = {"name": "x", "trigger_kind": "metric_threshold", "trigger_spec": {}, "eval_interval_secs": 10} + f = tmp_path / "a.json" + f.write_text(json.dumps(payload)) + result = runner.invoke(app, ["alerts", "create", "--file", str(f)]) + assert result.exit_code == 2 + + +def test_alerts_create_bad_trigger_kind(logged_in, runner): + result = runner.invoke(app, ["alerts", "create", "x", "--trigger-kind", "bogus", "--trigger-spec", "{}"]) + assert result.exit_code == 2 + + +_FULL_ALERT = { + "id": "a1", + "name": "p95", + "description": "latency guard", + "enabled": True, + "trigger_kind": "metric_threshold", + "trigger_spec": {"metric": "latency", "op": ">", "value": 100}, + "min_breaches": 1, + "eval_window": 1, + "eval_interval_secs": 300, + "severity": "warning", + "channels": [{"kind": "email", "recipients": ["x@y.z"]}], +} + + +@respx.mock +def test_alerts_update_flag_only_by_name_resends_full_body(logged_in, runner): + # A flag-only edit resolves the NAME via the list, then PUTs the WHOLE alert back with just + # the changed field — the server's PUT is a full replace. + respx.get(f"{BASE}/api/alerts").mock(return_value=httpx.Response(200, json=[_FULL_ALERT])) + put = respx.put(f"{BASE}/api/alerts/a1").mock(return_value=httpx.Response(200, json={"id": "a1", "updated_at": "t"})) + result = runner.invoke(app, ["--json", "alerts", "update", "p95", "--description", "off-hours", "--yes"]) + assert result.exit_code == 0, result.output + assert put.called + body = json.loads(put.calls.last.request.content) + assert body["description"] == "off-hours" # the changed field + assert body["name"] == "p95" # everything else carried over + assert body["trigger_kind"] == "metric_threshold" + assert body["trigger_spec"] == {"metric": "latency", "op": ">", "value": 100} + assert body["severity"] == "warning" + assert body["channels"] == [{"kind": "email", "recipients": ["x@y.z"]}] + assert body["enabled"] is True # preserved (no enable/disable flag any more) + + +@respx.mock +def test_alerts_update_flag_only_can_change_severity(logged_in, runner): + respx.get(f"{BASE}/api/alerts").mock(return_value=httpx.Response(200, json=[_FULL_ALERT])) + put = respx.put(f"{BASE}/api/alerts/a1").mock(return_value=httpx.Response(200, json={"id": "a1", "updated_at": "t"})) + result = runner.invoke(app, ["--json", "alerts", "update", "p95", "--severity", "critical", "--yes"]) + assert result.exit_code == 0, result.output + body = json.loads(put.calls.last.request.content) + assert body["severity"] == "critical" + assert body["enabled"] is True # untouched, preserved from the existing alert + + +@respx.mock +def test_alerts_update_missing_alert_is_not_found(logged_in, runner): + respx.get(f"{BASE}/api/alerts").mock(return_value=httpx.Response(200, json=[_FULL_ALERT])) + put = respx.put(f"{BASE}/api/alerts/a1").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["alerts", "update", "nope", "--severity", "critical", "--yes"]) + assert result.exit_code == 6, result.output + assert not put.called + + +@respx.mock +def test_alerts_update_with_file_is_full_replace(logged_in, runner, tmp_path): + # The --file path is a straight replace (the list read only resolves the name → id). + respx.get(f"{BASE}/api/alerts").mock(return_value=httpx.Response(200, json=[_FULL_ALERT])) + payload = {"name": "renamed", "trigger_kind": "metric_threshold", "trigger_spec": {"metric": "latency", "op": ">", "value": 1}, "severity": "info", "eval_interval_secs": 60} + f = tmp_path / "alert.json" + f.write_text(json.dumps(payload)) + put = respx.put(f"{BASE}/api/alerts/a1").mock(return_value=httpx.Response(200, json={"id": "a1", "updated_at": "t"})) + result = runner.invoke(app, ["--json", "alerts", "update", "p95", "--file", str(f), "--yes"]) + assert result.exit_code == 0, result.output + body = json.loads(put.calls.last.request.content) + assert body["name"] == "renamed" + assert body["severity"] == "info" + + +@respx.mock +def test_alerts_delete_by_name(logged_in, runner): + respx.get(f"{BASE}/api/alerts").mock(return_value=httpx.Response(200, json=[_FULL_ALERT])) + route = respx.delete(f"{BASE}/api/alerts/a1").mock(return_value=httpx.Response(204)) + result = runner.invoke(app, ["--json", "alerts", "delete", "p95", "--yes"]) + assert result.exit_code == 0, result.output + assert route.called + body = json.loads(result.stdout) + assert body["deleted"] is True and body["name"] == "p95" + + +@respx.mock +def test_alerts_delete_not_found_exits_6(logged_in, runner): + respx.get(f"{BASE}/api/alerts").mock(return_value=httpx.Response(200, json=[_FULL_ALERT])) + result = runner.invoke(app, ["alerts", "delete", "ghost", "--yes"]) + assert result.exit_code == 6 + + +@respx.mock +def test_alerts_test_sends_by_name(logged_in, runner): + respx.get(f"{BASE}/api/alerts").mock(return_value=httpx.Response(200, json=[_FULL_ALERT])) + respx.post(f"{BASE}/api/alerts/a1/test").mock( + return_value=httpx.Response(200, json={"ok": True, "synthetic_incident_id": "i1"}) + ) + result = runner.invoke(app, ["--json", "alerts", "test", "p95", "--yes"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["ok"] is True + + +@respx.mock +def test_alerts_test_human_renders_dispatch(logged_in, runner): + respx.get(f"{BASE}/api/alerts").mock(return_value=httpx.Response(200, json=[_FULL_ALERT])) + respx.post(f"{BASE}/api/alerts/a1/test").mock( + return_value=httpx.Response(200, json={"ok": True, "synthetic_incident_id": "i1"}) + ) + result = runner.invoke(app, ["alerts", "test", "p95", "--yes"]) + assert result.exit_code == 0, result.output + out = result.stdout + result.stderr + assert "test notification sent for" in out and "p95" in out + assert "dispatched to" in out and "email" in out # _FULL_ALERT's email channel + assert "delivery isn't confirmed" in out # honest note (issue #183) + + +# --- incidents -------------------------------------------------------------- + + +@respx.mock +def test_incidents_list_by_state(logged_in, runner): + route = respx.get(f"{BASE}/api/issues").mock( + return_value=httpx.Response(200, json=[{"id": "i1", "alert_name": "p95", "alert_severity": "warning", "state": "firing", "opened_at": "t", "assignees": []}]) + ) + result = runner.invoke(app, ["--json", "issues", "list", "--state", "firing"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["issues"][0]["id"] == "i1" + assert route.calls.last.request.url.params["state"] == "firing" + + +@respx.mock +def test_incidents_list_by_alert_uses_alert_path(logged_in, runner): + route = respx.get(f"{BASE}/api/alerts/a1/issues").mock(return_value=httpx.Response(200, json=[])) + result = runner.invoke(app, ["--json", "issues", "list", "--alert-id", "a1"]) + assert result.exit_code == 0, result.output + assert route.called + + +@respx.mock +def test_incidents_count_json(logged_in, runner): + respx.get(f"{BASE}/api/issues/count").mock(return_value=httpx.Response(200, json={"count": 3})) + result = runner.invoke(app, ["--json", "issues", "count"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["count"] == 3 + + +@respx.mock +def test_incident_ack(logged_in, runner): + route = respx.post(f"{BASE}/api/issues/i1/ack").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["--json", "issues", "ack", "i1"]) + assert result.exit_code == 0, result.output + assert route.called + + +@respx.mock +def test_incident_assign_sends_array(logged_in, runner): + route = respx.post(f"{BASE}/api/issues/i1/assign").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["--json", "issues", "assign", "i1", "--assignee", "a@x.com", "--assignee", "b@x.com"]) + assert result.exit_code == 0, result.output + assert json.loads(route.calls.last.request.content) == {"assignees": ["a@x.com", "b@x.com"]} + + +@respx.mock +def test_incident_comment_add_from_body(logged_in, runner): + respx.post(f"{BASE}/api/issues/i1/comments").mock( + return_value=httpx.Response(201, json={"id": "c1", "incident_id": "i1", "author_email": "me@test", "body": "hi", "created_at": "t"}) + ) + result = runner.invoke(app, ["--json", "issues", "comment-add", "i1", "--body", "hi"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["id"] == "c1" + + +@respx.mock +def test_incident_resolve_requires_yes(logged_in, runner): + route = respx.post(f"{BASE}/api/issues/i1/resolve").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["--json", "issues", "resolve", "i1", "--yes"]) + assert result.exit_code == 0, result.output + assert route.called + + +@respx.mock +def test_incident_open_standalone(logged_in, runner): + respx.post(f"{BASE}/api/issues").mock( + return_value=httpx.Response(201, json={"id": "i9", "newly_opened": True, "state": "firing"}) + ) + result = runner.invoke(app, ["--json", "issues", "open", "--summary", "manual", + "--title", "checkout 500s", "--severity", "critical"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["id"] == "i9" + # The title has to reach the wire — the server REQUIRES it on the orphan + # path, and omitting it is what made this command 422 unconditionally. + sent = json.loads(respx.calls.last.request.content) + assert sent["title"] == "checkout 500s" and sent["severity"] == "critical" + + +@respx.mock +def test_incident_open_standalone_without_title_exits_2(logged_in, runner): + """A standalone open with no --title is rejected client-side, before the + request: the server can only answer 422, so spending a round-trip on a + known-bad body just turns a usage error into a server error.""" + route = respx.post(f"{BASE}/api/issues").mock( + return_value=httpx.Response(201, json={"id": "i9"})) + result = runner.invoke(app, ["issues", "open", "--summary", "manual"]) + assert result.exit_code == 2, result.output + assert "--title is required" in result.output + assert not route.called + + +@respx.mock +def test_incident_open_linked_uses_alert_path(logged_in, runner): + route = respx.post(f"{BASE}/api/alerts/a1/issues").mock( + return_value=httpx.Response(201, json={"id": "i9", "newly_opened": True, "state": "firing"}) + ) + result = runner.invoke(app, ["--json", "issues", "open", "--summary", "x", "--alert-id", "a1"]) + assert result.exit_code == 0, result.output + assert route.called + + +# --- incidents: redesigned human UI + edge cases ---------------------------- + +_FULL_INCIDENT = { + "id": "1f5803aaaaaabbbbcccc000000009826", + "alert_id": "a1", + "alert_name": "p95 latency", + "alert_severity": "critical", + "state": "acknowledged", + "opened_at": "2026-06-28T00:00:00Z", + "acknowledged_by": "ops@example.com", + "assignees": ["a@example.com"], + "breach_summary": "p95 = 1240ms > 1000ms", + "comments": [ + {"id": "c1", "author_email": "ops@example.com", "body": "looking into it", "created_at": "2026-06-28T00:01:00Z"}, + {"id": "c2", "author_email": "x@example.com", "body": None, "created_at": "2026-06-28T00:02:00Z", "deleted_at": "2026-06-28T00:03:00Z"}, + ], + "subscribers": [{"email": "ops@example.com", "source": "ack", "subscribed_at": "2026-06-28T00:01:00Z"}], + "activity": [{"kind": "opened", "actor": "system", "at": "2026-06-28T00:00:00Z"}, + {"kind": "acknowledged", "actor": "ops@example.com", "at": "2026-06-28T00:01:00Z"}], +} + + +@respx.mock +def test_incidents_list_human_boxed(logged_in, runner): + respx.get(f"{BASE}/api/issues").mock(return_value=httpx.Response(200, json=[ + {"id": "1f5803aaaaaabbbbcccc000000009826", "alert_name": "p95", "alert_severity": "critical", + "state": "firing", "opened_at": "2026-06-28T00:00:00Z", "assignees": ["a@example.com"]}, + {"id": "z", "alert_name": None, "alert_severity": "info", "state": "resolved", + "opened_at": "2026-06-20T00:00:00Z", "assignees": []}, + ])) + result = runner.invoke(app, ["issues", "list"]) + assert result.exit_code == 0, result.output + out = result.stdout + result.stderr + assert "issues" in out + for c in ("alert", "severity", "state", "opened", "assignees"): + assert c in out + assert "1f58" in out # short id is the handle + assert "firing" in out and "resolved" in out # state words + footer distribution + + +@respx.mock +def test_incidents_list_invalid_state_exits_2(logged_in, runner): + result = runner.invoke(app, ["issues", "list", "--state", "bogus"]) + assert result.exit_code == 2 + + +@respx.mock +def test_incidents_list_limit_zero_exits_2(logged_in, runner): + result = runner.invoke(app, ["issues", "list", "--limit", "0"]) + assert result.exit_code == 2 + + +@respx.mock +def test_incidents_count_human_card(logged_in, runner): + respx.get(f"{BASE}/api/issues/count").mock(return_value=httpx.Response(200, json={"count": 4})) + result = runner.invoke(app, ["issues", "count", "--state", "firing"]) + assert result.exit_code == 0, result.output + out = result.stdout + assert "issues" in out and "4" in out and "firing issues" in out + + +@respx.mock +def test_incidents_show_human_cards(logged_in, runner): + respx.get(f"{BASE}/api/issues/i1").mock(return_value=httpx.Response(200, json=_FULL_INCIDENT)) + result = runner.invoke(app, ["issues", "show", "i1"]) + assert result.exit_code == 0, result.output + out = result.stdout + assert "p95 latency" in out and "critical" in out and "acknowledged" in out + assert "breach" in out and "1240ms" in out + assert "comments" in out and "looking into it" in out and "(deleted)" in out + assert "subscribers" in out and "ops@example.com" in out + assert "activity" in out and "opened" in out and "system" in out + + +@respx.mock +def test_incidents_show_not_found_exits_6(logged_in, runner): + respx.get(f"{BASE}/api/issues/ghost").mock(return_value=httpx.Response(404, json={"error": "Not found."})) + result = runner.invoke(app, ["issues", "show", "ghost"]) + assert result.exit_code == 6, result.output + assert "no issue" in result.stderr + assert "HTTP" not in (result.stdout + result.stderr) # never leak raw HTTP status + + +@respx.mock +def test_incidents_show_not_found_json(logged_in, runner): + respx.get(f"{BASE}/api/issues/ghost").mock(return_value=httpx.Response(404, json={"error": "Not found."})) + result = runner.invoke(app, ["--json", "issues", "show", "ghost"]) + assert result.exit_code == 6 + assert "no issue" in json.loads(result.stdout)["error"] + + +@respx.mock +def test_incidents_ack_human(logged_in, runner): + respx.post(f"{BASE}/api/issues/i1/ack").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["issues", "ack", "i1"]) + assert result.exit_code == 0, result.output + assert "acknowledged issue" in result.stderr + + +@respx.mock +def test_incidents_assign_non_operator_clean_error(logged_in, runner): + respx.post(f"{BASE}/api/issues/i1/assign").mock( + return_value=httpx.Response(422, json={"error": "a@x.com is not an operator"}) + ) + result = runner.invoke(app, ["issues", "assign", "i1", "--assignee", "a@x.com"]) + assert result.exit_code == 1, result.output # ApiError → exit 1 + assert "not an operator" in result.stderr + assert "HTTP" not in (result.stdout + result.stderr) + + +@respx.mock +def test_incidents_assign_non_operator_json(logged_in, runner): + respx.post(f"{BASE}/api/issues/i1/assign").mock( + return_value=httpx.Response(422, json={"error": "a@x.com is not an operator"}) + ) + result = runner.invoke(app, ["--json", "issues", "assign", "i1", "--assignee", "a@x.com"]) + assert result.exit_code == 1 + body = json.loads(result.stdout) + assert "not an operator" in body["error"] and body["status"] == 422 + + +@respx.mock +def test_incidents_resolve_human_yes(logged_in, runner): + respx.post(f"{BASE}/api/issues/i1/resolve").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["issues", "resolve", "i1", "--yes"]) + assert result.exit_code == 0, result.output + assert "resolved issue" in result.stderr + + +@respx.mock +def test_incidents_resolve_declined_emits_the_json_cancel_envelope(logged_in, runner, monkeypatch): + # Both docstrings promise `{cancelled: true}` under --json on a declined prompt, and + # every other write command emits it. `should_prompt` is forced here because --json + # normally auto-proceeds, which is what let these two paths drift to a stderr line + # only: a caller reading stdout would have got an empty document at exit 0. + monkeypatch.setattr(_write, "should_prompt", lambda *a, **k: True) + monkeypatch.setattr(output, "confirm_incident_resolve", lambda *a, **k: False) + respx.get(f"{BASE}/api/issues/i1").mock( # the prompt names the alert, so it reads first + return_value=httpx.Response(200, json={"id": "i1", "alert_name": "p95", "status": "open"})) + resolve = respx.post(f"{BASE}/api/issues/i1/resolve").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["--json", "issues", "resolve", "i1"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == {"cancelled": True} + assert not resolve.called + + +@respx.mock +def test_incidents_comment_delete_declined_emits_the_json_cancel_envelope(logged_in, runner, monkeypatch): + monkeypatch.setattr(_write, "should_prompt", lambda *a, **k: True) + monkeypatch.setattr(output, "confirm_incident_comment_delete", lambda *a, **k: False) + respx.get(f"{BASE}/api/issues/i1/comments").mock(return_value=httpx.Response(200, json=[ + {"id": "c1", "incident_id": "i1", "author_email": "me@test", "body": "typo", "created_at": "t"}])) + delete = respx.delete(f"{BASE}/api/issues/i1/comments/c1").mock(return_value=httpx.Response(204)) + result = runner.invoke(app, ["--json", "issues", "comment-delete", "i1", "c1"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == {"cancelled": True} + assert not delete.called + + +@respx.mock +def test_incidents_comment_add_human_card(logged_in, runner): + respx.post(f"{BASE}/api/issues/i1/comments").mock( + return_value=httpx.Response(201, json={"id": "c1", "incident_id": "i1", "author_email": "me@test", "body": "on it", "created_at": "t"}) + ) + result = runner.invoke(app, ["issues", "comment-add", "i1", "--body", "on it"]) + assert result.exit_code == 0, result.output + out = result.stdout + assert "comment added" in out and "on it" in out + + +def test_incidents_comment_add_neither_exits_2(logged_in, runner): + result = runner.invoke(app, ["issues", "comment-add", "i1"]) + assert result.exit_code == 2 + + +@respx.mock +def test_incidents_comment_delete_not_found_exits_6(logged_in, runner): + respx.get(f"{BASE}/api/issues/i1/comments").mock(return_value=httpx.Response(200, json=[])) + result = runner.invoke(app, ["issues", "comment-delete", "i1", "cX", "--yes"]) + assert result.exit_code == 6, result.output + assert "no comment" in result.stderr + + +@respx.mock +def test_incidents_comment_delete_human_yes(logged_in, runner): + respx.get(f"{BASE}/api/issues/i1/comments").mock(return_value=httpx.Response(200, json=[ + {"id": "c1", "incident_id": "i1", "author_email": "me@test", "body": "typo", "created_at": "2026-06-28T00:00:00Z"}])) + route = respx.delete(f"{BASE}/api/issues/i1/comments/c1").mock(return_value=httpx.Response(204)) + result = runner.invoke(app, ["issues", "comment-delete", "i1", "c1", "--yes"]) + assert result.exit_code == 0, result.output + assert route.called + assert "deleted comment" in result.stderr + + +@respx.mock +def test_incidents_comment_delete_json(logged_in, runner): + respx.get(f"{BASE}/api/issues/i1/comments").mock(return_value=httpx.Response(200, json=[ + {"id": "c1", "incident_id": "i1", "author_email": "me@test", "body": "typo", "created_at": "t"}])) + respx.delete(f"{BASE}/api/issues/i1/comments/c1").mock(return_value=httpx.Response(204)) + result = runner.invoke(app, ["--json", "issues", "comment-delete", "i1", "c1", "--yes"]) + assert result.exit_code == 0, result.output + body = json.loads(result.stdout) + assert body["deleted"] is True and body["id"] == "c1" + + +@respx.mock +def test_incidents_comment_list_human(logged_in, runner): + respx.get(f"{BASE}/api/issues/i1/comments").mock(return_value=httpx.Response(200, json=[ + {"id": "c1", "author_email": "ops@example.com", "body": "db pool exhausted", "created_at": "2026-06-28T00:00:00Z"}, + {"id": "c2", "author_email": "x@example.com", "body": None, "created_at": "2026-06-28T00:01:00Z", "deleted_at": "t"}, + ])) + result = runner.invoke(app, ["issues", "comment-list", "i1"]) + assert result.exit_code == 0, result.output + out = result.stdout + assert "comments" in out and "db pool exhausted" in out and "(deleted)" in out + + +@respx.mock +def test_incidents_subscribers_human(logged_in, runner): + respx.get(f"{BASE}/api/issues/i1/subscribers").mock(return_value=httpx.Response(200, json=[ + {"email": "ops@example.com", "source": "creator", "subscribed_at": "2026-06-28T00:00:00Z"}])) + result = runner.invoke(app, ["issues", "subscribers", "i1"]) + assert result.exit_code == 0, result.output + out = result.stdout + assert "subscribers" in out and "ops@example.com" in out and "creator" in out + + +@respx.mock +def test_incidents_subscribe_human(logged_in, runner): + respx.post(f"{BASE}/api/issues/i1/subscribe").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["issues", "subscribe", "i1"]) + assert result.exit_code == 0, result.output + assert "subscribed" in result.stderr and "you" in result.stderr + + +@respx.mock +def test_incidents_open_human_card(logged_in, runner): + respx.post(f"{BASE}/api/issues").mock( + return_value=httpx.Response(201, json={"id": "i9", "newly_opened": True, "state": "firing"})) + respx.get(f"{BASE}/api/issues/i9").mock(return_value=httpx.Response(200, json={ + "id": "i9", "state": "firing", "alert_severity": "critical", "title": "checkout 500s", + "opened_at": "2026-06-28T00:00:00Z"})) + result = runner.invoke(app, ["issues", "open", "--summary", "manual page", + "--title", "checkout 500s", "--severity", "critical"]) + assert result.exit_code == 0, result.output + out = result.stdout + assert "issue opened" in out and "manual page" in out and "critical" in out + assert "checkout 500s" in out # the title is the card's hero line + + +def test_incidents_open_bad_severity_exits_2(logged_in, runner): + result = runner.invoke(app, ["issues", "open", "--summary", "x", "--severity", "bogus"]) + assert result.exit_code == 2 + + +@respx.mock +def test_issues_show_malformed_id_is_not_found(logged_in, runner): + """The mirror of test_audits_finding_malformed_id_is_not_found, and it did not pass. + + The server's path extractor answers a non-UUID id with a 400 and a PLAIN-TEXT body, which + the dashboard converts to the generic {"error": "upstream returned non-JSON response"}. + `_fail` tested `status >= 500`, so the remap never fired: this exited 1 carrying that + internal phrase, while the audits sibling — the same code with `>= 400` — exited 6 with a + usable message. Anything branching on exit 6 to mean not-found took the wrong arm silently. + """ + respx.get(f"{BASE}/api/issues/not-a-uuid").mock( + return_value=httpx.Response(400, json={"error": "upstream returned non-JSON response"}) + ) + result = runner.invoke(app, ["--json", "issues", "show", "not-a-uuid"]) + assert result.exit_code == 6 + assert "no issue not-a-uuid" in json.loads(result.stdout)["error"] diff --git a/fp-cli/tests/test_analytics.py b/fp-cli/tests/test_analytics.py new file mode 100644 index 000000000..6084852da --- /dev/null +++ b/fp-cli/tests/test_analytics.py @@ -0,0 +1,380 @@ +"""Tests for CLI telemetry. + +The whole suite runs with telemetry **off** (``is_dev_or_test`` sees +``PYTEST_CURRENT_TEST``), so nothing here ever touches the network. Tests that need +the capture path force ``resolve_config`` to ``enabled`` and swap in a fake client +that records calls — mirroring the transport-fake style used elsewhere in the suite. +""" + +from __future__ import annotations + +import json + +import pytest + +from fp_cli import analytics +from fp_cli import analytics_config as acfg +from fp_cli import app as appmod +from fp_cli import config +from fp_cli._version import __version__ + + +class FakeClient: + """Stand-in for ``posthog.Posthog`` that records calls instead of sending.""" + + def __init__(self, api_key=None, **kwargs): + self.api_key = api_key + self.kwargs = kwargs + self.events = [] # list of (distinct_id, event, properties) + self.aliases = [] # list of (previous_id, distinct_id) + self.flushed = 0 + self.shutdowns = 0 + + def capture(self, distinct_id=None, event=None, properties=None, **_): + self.events.append((distinct_id, event, properties)) + + def alias(self, previous_id=None, distinct_id=None, **_): + self.aliases.append((previous_id, distinct_id)) + + def flush(self): + self.flushed += 1 + + def shutdown(self): + self.shutdowns += 1 + + +@pytest.fixture(autouse=True) +def _clean_analytics_state(): + """Reset the module singleton around every test.""" + analytics._client = None + analytics._distinct_id = None + analytics._command = None + analytics._json_output = False + analytics._auth_mode = "none" + analytics._force_anonymous = False + analytics._init_done = False # client is built lazily; reset the one-shot guard + analytics._pending_conf = None + yield + analytics.shutdown() + analytics._client = None + analytics._init_done = False + analytics._pending_conf = None + analytics._force_anonymous = False + + +@pytest.fixture +def force_enabled(monkeypatch): + """Force telemetry 'enabled' despite the pytest gate, backed by a fake client.""" + monkeypatch.setattr( + acfg, + "resolve_config", + lambda: acfg.AnalyticsConfig(enabled=True, api_key="phc_test", host="http://ph.test"), + ) + import posthog + + monkeypatch.setattr(posthog, "Posthog", FakeClient) + return FakeClient + + +# --- gating / opt-out ------------------------------------------------------------ + + +def test_disabled_under_pytest(monkeypatch): + for var in ("FP_ANALYTICS_DISABLED", "DO_NOT_TRACK", "FP_CLI_DEV"): + monkeypatch.delenv(var, raising=False) + # PYTEST_CURRENT_TEST is set by pytest, so the dev/test gate alone disables us. + assert acfg.is_dev_or_test() is True + assert acfg.resolve_config().enabled is False + analytics.init_analytics(config.CliConfig()) + analytics._ensure_client() # force the (deferred) build — stays off because disabled + assert analytics._client is None + + +@pytest.mark.parametrize("var", ["FP_ANALYTICS_DISABLED", "DO_NOT_TRACK"]) +@pytest.mark.parametrize("val", ["1", "true", "TRUE", "yes", "Yes"]) +def test_opt_out_truthy(monkeypatch, var, val): + monkeypatch.setenv(var, val) + assert acfg.is_disabled() is True + + +@pytest.mark.parametrize("val", ["0", "false", "no", "", " "]) +def test_opt_out_falsey(monkeypatch, val): + monkeypatch.delenv("DO_NOT_TRACK", raising=False) + monkeypatch.setenv("FP_ANALYTICS_DISABLED", val) + assert acfg.is_disabled() is False + + +def test_init_noop_when_opted_out(monkeypatch): + # Even with the pytest gate bypassed, the opt-out env var keeps us off. + monkeypatch.setattr(acfg, "is_dev_or_test", lambda: False) + monkeypatch.setenv("FP_ANALYTICS_DISABLED", "1") + analytics.init_analytics(config.CliConfig()) + analytics._ensure_client() # force the (deferred) build — stays off because opted out + assert analytics._client is None + + +# --- distinct id / identity ------------------------------------------------------ + + +def test_anonymous_id_generated_and_persisted(home, force_enabled): + analytics.init_analytics(config.load_config()) + analytics._ensure_client() # client is built lazily on first use + assert analytics._client is not None + anon = config.load_config().anonymous_id + assert anon # persisted to cli.json + assert analytics._distinct_id == anon # logged-out -> anonymous distinct id + + +def test_logged_in_uses_user_id_not_anonymous(home, force_enabled): + config.save_config( + config.CliConfig(base_url="http://d", session_token="tok", user_id="u-42") + ) + analytics.init_analytics(config.load_config()) + analytics._ensure_client() # client is built lazily on first use + assert analytics._distinct_id == "u-42" + # No anonymous id is minted while logged in. + assert config.load_config().anonymous_id is None + + +def test_super_properties_tag_product(home, force_enabled): + analytics.init_analytics(config.load_config()) + analytics._ensure_client() # client is built lazily on first use + sup = analytics._client.kwargs["super_properties"] + # Must match analytics_config.PRODUCT exactly — it is the discriminator that keeps + # this CLI's events apart from the Enforcement CLI's in the shared PostHog project. + assert sup["product"] == "fp-cli" + assert sup["cli_version"] == __version__ + assert sup["os"] and sup["python_version"] + # The client is constructed against the direct ingest host, geoip disabled. + assert analytics._client.kwargs["disable_geoip"] is True + + +def test_identify_links_anonymous_to_user(home, force_enabled): + config.save_config(config.CliConfig(anonymous_id="anon-1")) + analytics.init_analytics(config.load_config()) + analytics.identify("user-xyz") + assert analytics._client.aliases == [("anon-1", "user-xyz")] + + +def test_identify_noop_when_disabled(): + # No force_enabled -> client is None; must not raise. + analytics.init_analytics(config.CliConfig()) + analytics.identify("user-xyz") # no-op, no exception + + +def test_reset_rotates_anonymous_id(home, force_enabled): + config.save_config(config.CliConfig(anonymous_id="old-anon")) + analytics.init_analytics(config.load_config()) + analytics.reset() + rotated = config.load_config().anonymous_id + assert rotated and rotated != "old-anon" + + +def test_reset_rotates_even_when_disabled(home): + # The anonymous id is persistent state and the opt-out flag can flip between runs, + # so logout must rotate it regardless of whether telemetry is active this run. + config.save_config(config.CliConfig(anonymous_id="keep")) + analytics.init_analytics(config.CliConfig()) # disabled -> client None + analytics.reset() + after = config.load_config().anonymous_id + assert after and after != "keep" + + +# --- command_executed payload / privacy ------------------------------------------ + + +def _only_event(client): + assert len(client.events) == 1 + distinct_id, event, props = client.events[0] + return distinct_id, event, props + + +def test_command_executed_payload_allowlist(home, force_enabled): + analytics.init_analytics(config.load_config()) + analytics.note_command("sessions", True) + argv = [ + "--json", "sessions", "--since", "24h", + "--base-url", "https://secret.internal", + "--score", "helpfulness:..0.5", + ] + analytics.capture_command(exit_code=0, duration_ms=12, argv=argv) + + _, event, props = _only_event(analytics._client) + assert event == "command_executed" + assert props == { + "command": "sessions", + "subcommand": None, + "success": True, + "exit_code": 0, + "error_category": None, + "duration_ms": 12, + "flags": ["--json", "--since", "--base-url", "--score"], + "json_output": True, + "auth_mode": "none", + } + # No argument VALUES leak — serialise the whole payload and scan. + blob = json.dumps(props) + for secret in ("secret.internal", "24h", "helpfulness", "0.5"): + assert secret not in blob + + +def test_command_executed_group_subcommand(home, force_enabled): + analytics.init_analytics(config.load_config()) + analytics.note_command("agent", False) + analytics.capture_command( + exit_code=0, duration_ms=3, argv=["agent", "rename", "chat-001", "--title", "secret title"] + ) + _, _, props = _only_event(analytics._client) + assert props["command"] == "agent" + assert props["subcommand"] == "rename" + assert props["flags"] == ["--title"] + blob = json.dumps(props) + assert "chat-001" not in blob and "secret title" not in blob + + +def test_auth_mode_rides_on_command_executed(home, force_enabled): + from fp_cli.client import AuthMode + + analytics.init_analytics(config.load_config()) + analytics.note_command("sessions", False, auth_mode=AuthMode.API_KEY) + analytics.capture_command( + exit_code=0, duration_ms=1, argv=["--api-key", "ak_live_SECRET", "sessions"] + ) + _, _, props = _only_event(analytics._client) + assert props["auth_mode"] == "api_key" + # The mode is the whole signal: never the key, its length, or a prefix. + blob = json.dumps(props) + assert "ak_live_SECRET" not in blob and "SECRET" not in blob + assert props["flags"] == ["--api-key"] # the NAME only + + +@pytest.mark.parametrize("given,expected", [ + ("session", "session"), + ("api_key", "api_key"), + (None, "none"), + ("something-else", "none"), # closed enum: an unexpected value degrades, never rides along +]) +def test_auth_mode_is_a_closed_enum(home, force_enabled, given, expected): + analytics.init_analytics(config.load_config()) + analytics.note_command("whoami", False, auth_mode=given) + analytics.capture_command(exit_code=0, duration_ms=1, argv=["whoami"]) + _, _, props = _only_event(analytics._client) + assert props["auth_mode"] == expected + + +def test_key_mode_forces_the_anonymous_distinct_id(home, force_enabled): + # A CI box with a logged-in human on it: the key's commands must NOT be attributed + # to that person. + config.save_config( + config.CliConfig(base_url="http://d", session_token="tok", user_id="u-42") + ) + analytics.init_analytics(config.load_config(), force_anonymous=True) + analytics._ensure_client() + assert analytics._distinct_id != "u-42" + assert analytics._distinct_id == config.load_config().anonymous_id + + +def test_unknown_command_is_dropped(home, force_enabled): + analytics.init_analytics(config.load_config()) + analytics.note_command("rm-rf-everything", False) # not a real command + analytics.capture_command(exit_code=0, duration_ms=1, argv=["rm-rf-everything"]) + _, _, props = _only_event(analytics._client) + assert props["command"] is None + + +@pytest.mark.parametrize( + "code,category,success", + [ + (0, None, True), + (2, "usage", False), + (3, "network", False), + (4, "auth", False), + (5, "forbidden", False), + (6, "not_found", False), + (1, "error", False), + ], +) +def test_error_category_from_exit_code(home, force_enabled, code, category, success): + analytics.init_analytics(config.load_config()) + analytics.note_command("whoami", False) + analytics.capture_command(exit_code=code, duration_ms=1, argv=["whoami"]) + _, _, props = _only_event(analytics._client) + assert props["error_category"] == category + assert props["success"] is success + + +# --- helpers --------------------------------------------------------------------- + + +def test_sanitize_flags_keeps_names_drops_values(): + argv = [ + "--base-url", "https://secret", "--token", "abc", "--email", "a@b.com", + "--score", "k:..0.5", "sessions", "-q", "--unknown-xyz", "-5", + "--fields=session_id,scores", + ] + flags = analytics._sanitize_flags(argv) + assert flags == ["--base-url", "--token", "--email", "--score", "--quiet", "--fields"] + blob = " ".join(flags) + for leak in ("secret", "abc", "a@b.com", "k:..0.5", "session_id"): + assert leak not in blob + + +@pytest.mark.parametrize( + "argv,expected", + [ + (["agent", "show", "x"], "show"), + (["agent", "rename"], "rename"), + (["--json", "agent", "show"], "show"), + (["agent", "rename", "show"], "rename"), # first after 'agent' wins + (["sessions", "--all"], None), # 'sessions' is a leaf, not a group + (["whoami"], None), + ], +) +def test_detect_subcommand(argv, expected): + assert analytics._detect_subcommand(argv) == expected + + +def test_shutdown_is_idempotent(home, force_enabled): + analytics.init_analytics(config.load_config()) + analytics._ensure_client() # client is built lazily on first use + client = analytics._client + analytics.shutdown() + assert analytics._client is None + assert client.flushed == 1 and client.shutdowns == 1 + analytics.shutdown() # second call: no-op, no error + assert client.flushed == 1 + + +# --- entry-point wrapper preserves exit codes ------------------------------------ + + +def test_main_entry_preserves_exit_code(monkeypatch): + recorded = {} + + def boom(): + raise SystemExit(4) + + monkeypatch.setattr(appmod, "app", boom) + monkeypatch.setattr( + appmod.analytics, "capture_command", lambda code, ms, argv: recorded.update(code=code) + ) + monkeypatch.setattr(appmod.analytics, "shutdown", lambda: None) + + with pytest.raises(SystemExit) as exc: + appmod.main_entry() + assert exc.value.code == 4 + assert recorded["code"] == 4 + + +def test_main_entry_success_path(monkeypatch): + recorded = {} + monkeypatch.setattr(appmod, "app", lambda: None) # returns without exiting + monkeypatch.setattr( + appmod.analytics, "capture_command", lambda code, ms, argv: recorded.update(code=code) + ) + monkeypatch.setattr(appmod.analytics, "shutdown", lambda: None) + + with pytest.raises(SystemExit) as exc: + appmod.main_entry() + assert exc.value.code == 0 + assert recorded["code"] == 0 diff --git a/fp-cli/tests/test_audits.py b/fp-cli/tests/test_audits.py new file mode 100644 index 000000000..72b9ee775 --- /dev/null +++ b/fp-cli/tests/test_audits.py @@ -0,0 +1,950 @@ +"""Audits: definitions CRUD + runs + findings triage.""" + +from __future__ import annotations + +import json + +import httpx +import pytest +import respx + +from fp_cli import analytics +from fp_cli.app import app + +BASE = "http://dash.test" + +_FULL_AUDIT = { + "id": "au1", + "name": "nightly-prod", + "description": "nightly failure sweep", + "enabled": True, + "schedule_interval_secs": 86400, + "window_mode": "since_last", + "lookback_window_secs": 604800, + "scope": {"environments": ["prod"]}, + "ignore_error_types": ["TimeoutError"], + "llm_enabled": True, + "top_k": 50, + "sensitivity": "medium", + "channels": [{"kind": "slack"}], + "created_by": "ops@example.com", + "created_at": "2026-06-28T00:00:00Z", + "updated_at": "2026-06-28T00:00:00Z", + "open_findings": 3, + "last_run_status": "succeeded", + "last_run_finished_at": "2026-06-28T01:00:00Z", +} + +_FULL_FINDING = { + "id": "1f5803aa-aaaa-bbbb-cccc-000000009826", + "audit_id": "au1", + "audit_name": "nightly-prod", + "fingerprint": "fp-1", + "title": "retry storm on checkout tool", + "category": "reliability", + "failure_type": "tool_error", + "description": "the checkout tool is retried until the budget is exhausted", + "root_cause_hypothesis": "upstream 502s are not treated as terminal", + "severity": "critical", + "magnitude": "big", + "priority": 0.92, + "status": "open", + "occurrences": 41, + "first_seen_at": "2026-06-20T00:00:00Z", + "last_seen_at": "2026-06-28T00:00:00Z", + "recommendation": "treat 502 as terminal and fail fast", + "expected_impact": "removes ~40 wasted calls a day", + "effort": "small", + "evidence": {"sessions": ["run-001"]}, + "evidence_queries": ["SELECT 1"], + "scope": {"environments": ["prod"]}, + "kind": "failure", + "assigned_to": None, +} + + +# --- audits: definitions ---------------------------------------------------- + + +@respx.mock +def test_audits_list_json(logged_in, runner): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + result = runner.invoke(app, ["--json", "audits", "list"]) + assert result.exit_code == 0, result.output + body = json.loads(result.stdout)["audits"][0] + assert body["id"] == "au1" and body["name"] == "nightly-prod" + assert body["scope"] == {"environments": ["prod"]} # opaque blob passes through untouched + assert body["open_findings"] == 3 + + +@respx.mock +def test_audits_list_human_boxed(logged_in, runner): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[ + _FULL_AUDIT, + {**_FULL_AUDIT, "id": "au2", "name": "paused", "enabled": False, "open_findings": 0, + "created_at": "2026-06-20T00:00:00Z", "last_run_status": None, + "last_run_finished_at": None}, + ])) + result = runner.invoke(app, ["audits", "list"]) + assert result.exit_code == 0, result.output + out = result.stdout + result.stderr + assert "audits" in out and "newest first" in out + for c in ("created", "name", "every", "findings", "status", "last run"): + assert c in out + assert "nightly-prod" in out and "paused" in out + assert "1d" in out # humanized 86400s schedule + assert "never" in out # the paused audit has never run + assert "on" in out and "off" in out # the on/off split in the footer + assert "3 open findings" in out # footer findings roll-up + + +@respx.mock +def test_audits_list_enabled_only(logged_in, runner): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[ + _FULL_AUDIT, {**_FULL_AUDIT, "id": "au2", "name": "paused", "enabled": False}])) + result = runner.invoke(app, ["--json", "audits", "list", "--enabled-only"]) + assert result.exit_code == 0, result.output + audits = json.loads(result.stdout)["audits"] + assert [a["name"] for a in audits] == ["nightly-prod"] + + +@respx.mock +def test_audits_show_by_name_json(logged_in, runner): + # `show` resolves the NAME through the list endpoint (no GET by id needed). + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + result = runner.invoke(app, ["--json", "audits", "show", "nightly-prod"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["scope"] == {"environments": ["prod"]} + + +@respx.mock +def test_audits_show_human_cards(logged_in, runner): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + result = runner.invoke(app, ["audits", "show", "nightly-prod"]) + assert result.exit_code == 0, result.output + out = result.stdout + assert "nightly-prod" in out and "enabled" in out and "3 open findings" in out + assert "schedule" in out and "runs every" in out and "1d" in out + assert "scope" in out and "prod" in out and "TimeoutError" in out # covers + ignores + assert "analysis" in out and "sensitivity" in out and "medium" in out + assert "channels" in out and "slack" in out + + +@respx.mock +def test_audits_show_not_found_exits_6(logged_in, runner): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + result = runner.invoke(app, ["audits", "show", "ghost"]) + assert result.exit_code == 6 + + +@respx.mock +def test_audits_create_sends_definition(logged_in, runner): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[])) # no collision + route = respx.post(f"{BASE}/api/audits").mock( + return_value=httpx.Response(201, json={"id": "au9", "created_at": "t"})) + result = runner.invoke(app, [ + "--json", "audits", "create", "nightly-prod", + "--scope", '{"environments":["prod"]}', + "--schedule-interval-secs", "86400", + "--sensitivity", "high", + "--ignore-error-type", "TimeoutError,ValueError", + ]) + assert result.exit_code == 0, result.output + body = json.loads(route.calls.last.request.content) + assert body["name"] == "nightly-prod" + assert body["scope"] == {"environments": ["prod"]} + assert body["sensitivity"] == "high" + assert body["ignore_error_types"] == ["TimeoutError", "ValueError"] # CSV → list + assert json.loads(result.stdout)["id"] == "au9" + + +@respx.mock +def test_audits_create_from_file(logged_in, runner, tmp_path): + payload = {"name": "weekly", "schedule_interval_secs": 604800, "sensitivity": "low"} + f = tmp_path / "audit.json" + f.write_text(json.dumps(payload)) + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[])) + route = respx.post(f"{BASE}/api/audits").mock( + return_value=httpx.Response(201, json={"id": "au9", "created_at": "t"})) + result = runner.invoke(app, ["--json", "audits", "create", "weekly", "--file", str(f), + "--sensitivity", "high"]) # a flag overrides the file + assert result.exit_code == 0, result.output + body = json.loads(route.calls.last.request.content) + assert body["schedule_interval_secs"] == 604800 and body["sensitivity"] == "high" + + +@respx.mock +def test_audits_create_sends_context_in_the_same_request(logged_in, runner): + """Context goes with the definition, not in a follow-up request. + + Creating an enabled audit queues its first run due immediately, so a second + request can lose the race: the run an operator watches would argue without + the brief they just attached. No `PUT .../context` route is registered here + deliberately — if the command made that call, respx would fail the test.""" + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[])) + route = respx.post(f"{BASE}/api/audits").mock( + return_value=httpx.Response(201, json={"id": "au9", "created_at": "t", "sources": 2})) + other = "https://docs.example.com/slo" + result = runner.invoke(app, [ + "--json", "audits", "create", "nightly-prod", + "--text", "checkout agent for a retail store", + "--url", _DOC_URL, "--url", other, + ]) + assert result.exit_code == 0, result.output + body = json.loads(route.calls.last.request.content) + assert body["context"] == { + "text": "checkout agent for a retail store", + "urls": [_DOC_URL, other], + } + + +@respx.mock +def test_audits_create_without_context_sends_no_context_key(logged_in, runner): + """An omitted brief must not become an empty one — `context` absent, not `{}`.""" + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[])) + route = respx.post(f"{BASE}/api/audits").mock( + return_value=httpx.Response(201, json={"id": "au9", "created_at": "t"})) + result = runner.invoke(app, ["--json", "audits", "create", "nightly-prod"]) + assert result.exit_code == 0, result.output + assert "context" not in json.loads(route.calls.last.request.content) + + +@respx.mock +def test_audits_create_reads_the_brief_from_a_file(logged_in, runner, tmp_path): + f = tmp_path / "brief.md" + f.write_text("# Checkout agent\n\nRetries are expected on 429.") + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[])) + route = respx.post(f"{BASE}/api/audits").mock( + return_value=httpx.Response(201, json={"id": "au9", "created_at": "t", "sources": 0})) + result = runner.invoke(app, ["--json", "audits", "create", "nightly-prod", + "--text-file", str(f)]) + assert result.exit_code == 0, result.output + body = json.loads(route.calls.last.request.content) + assert body["context"]["text"].startswith("# Checkout agent") + assert body["context"]["urls"] == [] + + +def test_audits_create_rejects_too_many_urls(logged_in, runner): + # The server's cap, applied before the request goes out. + result = runner.invoke(app, ["audits", "create", "x"] + sum( + (["--url", f"https://docs.example.com/{i}"] for i in range(6)), [])) + assert result.exit_code == 2, result.output + + +def test_audits_create_rejects_text_and_text_file_together(logged_in, runner): + result = runner.invoke(app, ["audits", "create", "x", "--text", "a", "--text-file", "b.md"]) + assert result.exit_code == 2, result.output + + +@respx.mock +def test_audits_create_human_renders_card(logged_in, runner): + # Human mode: pre-check (no collision) → POST → re-read the canonical audit → render the card. + respx.get(f"{BASE}/api/audits").mock(side_effect=[ + httpx.Response(200, json=[]), # pre-check: no collision + httpx.Response(200, json=[_FULL_AUDIT]), # re-fetch after the write + ]) + respx.post(f"{BASE}/api/audits").mock( + return_value=httpx.Response(201, json={"id": "au1", "created_at": "t"})) + result = runner.invoke(app, ["audits", "create", "nightly-prod"]) + assert result.exit_code == 0, result.output + out = result.stdout + assert "audit created" in out and "nightly-prod" in out + assert "schedule" in out and "channels" in out # the same cards `show` renders + + +@respx.mock +def test_audits_create_renders_from_body_when_refetch_misses(logged_in, runner): + # If the post-write re-read doesn't find the audit, the card still renders — built from the + # request body — rather than crashing on the missing canonical row. + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[])) + respx.post(f"{BASE}/api/audits").mock( + return_value=httpx.Response(201, json={"id": "au9", "created_at": "t"})) + result = runner.invoke(app, ["audits", "create", "weekly", "--sensitivity", "high", + "--scope", '{"environments":["prod"]}']) + assert result.exit_code == 0, result.output + out = result.stdout + assert "audit created" in out and "weekly" in out + assert "high" in out and "prod" in out + + +@respx.mock +def test_audits_create_name_collision_exits_2(logged_in, runner): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + result = runner.invoke(app, ["--json", "audits", "create", "nightly-prod"]) + assert result.exit_code == 2 + assert "already exists" in json.loads(result.stdout)["error"] + + +def test_audits_create_bad_interval_exits_2(logged_in, runner): + # Below the server's 1h floor — rejected locally, before any HTTP call. + result = runner.invoke(app, ["audits", "create", "x", "--schedule-interval-secs", "60"]) + assert result.exit_code == 2 + + +def test_audits_create_bad_window_mode_exits_2(logged_in, runner): + result = runner.invoke(app, ["audits", "create", "x", "--window-mode", "bogus"]) + assert result.exit_code == 2 + + +def test_audits_create_bad_sensitivity_exits_2(logged_in, runner): + result = runner.invoke(app, ["audits", "create", "x", "--sensitivity", "bogus"]) + assert result.exit_code == 2 + + +@respx.mock +def test_audits_edit_flag_only_resends_full_body(logged_in, runner): + # A flag-only edit resolves the NAME via the list, then PUTs the WHOLE definition back with + # just the changed field — the server replaces the definition on update. + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + put = respx.put(f"{BASE}/api/audits/au1").mock( + return_value=httpx.Response(200, json={"id": "au1", "updated": True})) + result = runner.invoke(app, ["--json", "audits", "edit", "nightly-prod", + "--sensitivity", "high", "--yes"]) + assert result.exit_code == 0, result.output + body = json.loads(put.calls.last.request.content) + assert body["sensitivity"] == "high" # the changed field + assert body["name"] == "nightly-prod" # everything else carried over + assert body["scope"] == {"environments": ["prod"]} + assert body["schedule_interval_secs"] == 86400 + assert body["channels"] == [{"kind": "slack"}] + assert body["enabled"] is True + assert "open_findings" not in body # server-derived state never sent + + +@respx.mock +def test_audits_edit_disable(logged_in, runner): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + put = respx.put(f"{BASE}/api/audits/au1").mock( + return_value=httpx.Response(200, json={"id": "au1", "updated": True})) + result = runner.invoke(app, ["--json", "audits", "edit", "nightly-prod", "--disabled", "--yes"]) + assert result.exit_code == 0, result.output + assert json.loads(put.calls.last.request.content)["enabled"] is False + + +@respx.mock +def test_audits_edit_human_renders_card(logged_in, runner): + # First GET resolves the name (old state); the post-PUT re-fetch returns the new state. + respx.get(f"{BASE}/api/audits").mock(side_effect=[ + httpx.Response(200, json=[_FULL_AUDIT]), + httpx.Response(200, json=[{**_FULL_AUDIT, "sensitivity": "high"}]), + ]) + respx.put(f"{BASE}/api/audits/au1").mock( + return_value=httpx.Response(200, json={"id": "au1", "updated": True})) + result = runner.invoke(app, ["audits", "edit", "nightly-prod", "--sensitivity", "high", "--yes"]) + assert result.exit_code == 0, result.output + out = result.stdout + assert "audit updated" in out and "nightly-prod" in out and "high" in out + + +@respx.mock +def test_audits_edit_rename_collision_exits_2(logged_in, runner): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[ + _FULL_AUDIT, {**_FULL_AUDIT, "id": "au2", "name": "taken"}])) + result = runner.invoke(app, ["--json", "audits", "edit", "nightly-prod", "--name", "taken", "--yes"]) + assert result.exit_code == 2 + assert "already exists" in json.loads(result.stdout)["error"] + + +@respx.mock +def test_audits_edit_missing_audit_exits_6(logged_in, runner): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + put = respx.put(f"{BASE}/api/audits/au1").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["audits", "edit", "ghost", "--sensitivity", "high", "--yes"]) + assert result.exit_code == 6, result.output + assert not put.called + + +@respx.mock +def test_audits_delete_by_name(logged_in, runner): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + route = respx.delete(f"{BASE}/api/audits/au1").mock(return_value=httpx.Response(204)) + result = runner.invoke(app, ["--json", "audits", "delete", "nightly-prod", "--yes"]) + assert result.exit_code == 0, result.output + assert route.called + body = json.loads(result.stdout) + assert body["deleted"] is True and body["name"] == "nightly-prod" + + +@respx.mock +def test_audits_delete_not_found_exits_6(logged_in, runner): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + result = runner.invoke(app, ["audits", "delete", "ghost", "--yes"]) + assert result.exit_code == 6 + + +# --- audits: run + runs ----------------------------------------------------- + + +@respx.mock +def test_audits_run_queues_202(logged_in, runner): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + route = respx.post(f"{BASE}/api/audits/au1/run").mock( + return_value=httpx.Response(202, json={"queued": True})) + result = runner.invoke(app, ["--json", "audits", "run", "nightly-prod"]) + assert result.exit_code == 0, result.output + assert route.called + assert json.loads(result.stdout)["queued"] is True + + +@respx.mock +def test_audits_run_human(logged_in, runner): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + respx.post(f"{BASE}/api/audits/au1/run").mock( + return_value=httpx.Response(202, json={"queued": True})) + result = runner.invoke(app, ["audits", "run", "nightly-prod"]) + assert result.exit_code == 0, result.output + out = result.stdout + result.stderr + assert "queued a run for" in out and "nightly-prod" in out + + +@respx.mock +def test_audits_run_conflict_409(logged_in, runner): + # A run already in progress → the server's 409 message, never a double-queue. + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + respx.post(f"{BASE}/api/audits/au1/run").mock(return_value=httpx.Response( + 409, json={"error": "a run is already in progress; it must finish before another can be queued"})) + result = runner.invoke(app, ["audits", "run", "nightly-prod"]) + assert result.exit_code == 1, result.output # ApiError → exit 1 + assert "already in progress" in result.stderr + assert "HTTP" not in (result.stdout + result.stderr) # never leak the raw status + + +@respx.mock +def test_audits_run_conflict_409_json(logged_in, runner): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + respx.post(f"{BASE}/api/audits/au1/run").mock(return_value=httpx.Response( + 409, json={"error": "a run is already in progress; it must finish before another can be queued"})) + result = runner.invoke(app, ["--json", "audits", "run", "nightly-prod"]) + assert result.exit_code == 1 + body = json.loads(result.stdout) + assert "already in progress" in body["error"] and body["status"] == 409 + assert "audits runs" in body["hint"] + + +@respx.mock +def test_audits_runs_json(logged_in, runner): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + respx.get(f"{BASE}/api/audits/au1/runs").mock(return_value=httpx.Response(200, json=[ + {"id": "r1", "status": "succeeded", "trigger_kind": "schedule", + "window_from": "2026-06-27T00:00:00Z", "window_to": "2026-06-28T00:00:00Z", + "started_at": "2026-06-28T00:00:00Z", "finished_at": "2026-06-28T00:00:30Z", + "stats": {"events": 100}, "findings_count": 4, "new_findings_count": 1, + "report": "all good", "error": None}, + ])) + result = runner.invoke(app, ["--json", "audits", "runs", "nightly-prod"]) + assert result.exit_code == 0, result.output + run = json.loads(result.stdout)["runs"][0] + assert run["id"] == "r1" and run["findings_count"] == 4 and run["stats"] == {"events": 100} + + +@respx.mock +def test_audits_runs_human_boxed(logged_in, runner): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + respx.get(f"{BASE}/api/audits/au1/runs").mock(return_value=httpx.Response(200, json=[ + {"id": "r1", "status": "succeeded", "trigger_kind": "schedule", + "started_at": "2026-06-28T00:00:00Z", "finished_at": "2026-06-28T00:00:30Z", + "findings_count": 4, "new_findings_count": 1}, + {"id": "r2", "status": "failed", "trigger_kind": "manual", + "started_at": "2026-06-27T00:00:00Z", "finished_at": "2026-06-27T00:00:10Z", + "findings_count": 0, "new_findings_count": 0, "error": "clickhouse timeout"}, + ])) + result = runner.invoke(app, ["audits", "runs", "nightly-prod"]) + assert result.exit_code == 0, result.output + out = result.stdout + result.stderr + assert "runs" in out and "nightly-prod" in out + for c in ("started", "status", "trigger", "findings", "new", "took"): + assert c in out + assert "succeeded" in out and "failed" in out and "30s" in out # computed wall time + + +@respx.mock +def test_audits_runs_limit_zero_exits_2(logged_in, runner): + result = runner.invoke(app, ["audits", "runs", "nightly-prod", "--limit", "0"]) + assert result.exit_code == 2 + + +# --- audits: findings ------------------------------------------------------- + + +@respx.mock +def test_audits_findings_json_with_filters(logged_in, runner): + route = respx.get(f"{BASE}/api/audits/findings").mock( + return_value=httpx.Response(200, json=[_FULL_FINDING])) + result = runner.invoke(app, ["--json", "audits", "findings", "--status", "open,recurring", + "--run-id", "r1", "--limit", "20", "--offset", "5"]) + assert result.exit_code == 0, result.output + params = route.calls.last.request.url.params + assert params["status"] == "open,recurring" + assert params["run_id"] == "r1" + assert params["limit"] == "20" and params["offset"] == "5" + assert json.loads(result.stdout)["findings"][0]["id"] == _FULL_FINDING["id"] + + +@respx.mock +def test_audits_findings_by_audit_name_resolves_id(logged_in, runner): + # `--audit` takes the audit NAME; the CLI resolves it to the id the server filter wants. + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + route = respx.get(f"{BASE}/api/audits/findings").mock(return_value=httpx.Response(200, json=[])) + result = runner.invoke(app, ["--json", "audits", "findings", "--audit", "nightly-prod"]) + assert result.exit_code == 0, result.output + assert route.calls.last.request.url.params["audit_id"] == "au1" + + +@respx.mock +def test_audits_findings_unknown_audit_exits_6(logged_in, runner): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + result = runner.invoke(app, ["audits", "findings", "--audit", "ghost"]) + assert result.exit_code == 6 + + +@respx.mock +def test_audits_findings_human_boxed(logged_in, runner): + respx.get(f"{BASE}/api/audits/findings").mock(return_value=httpx.Response(200, json=[ + _FULL_FINDING, + {**_FULL_FINDING, "id": "22220000-aaaa-bbbb-cccc-000000001111", "title": "slow tool", + "severity": "warning", "status": "muted", "kind": "improvement", "occurrences": 2}, + ])) + result = runner.invoke(app, ["audits", "findings"]) + assert result.exit_code == 0, result.output + out = result.stdout + result.stderr + assert "findings" in out and "highest priority first" in out + for c in ("title", "severity", "status", "kind", "seen", "last"): + assert c in out + assert "1f58" in out # short id is the handle + # The title truncates to the leftover width (80-col test console) so the fixed columns + # always survive — the full text is in `audits finding` / --json. + assert "retry" in out and "critical" in out + assert "open" in out and "muted" in out # status words + footer distribution + assert "1 critical" in out # footer severity roll-up + + +def test_audits_findings_bad_status_exits_2(logged_in, runner): + result = runner.invoke(app, ["audits", "findings", "--status", "bogus"]) + assert result.exit_code == 2 + + +def test_audits_findings_limit_zero_exits_2(logged_in, runner): + result = runner.invoke(app, ["audits", "findings", "--limit", "0"]) + assert result.exit_code == 2 + + +def test_audits_findings_negative_offset_exits_2(logged_in, runner): + result = runner.invoke(app, ["audits", "findings", "--offset", "-1"]) + assert result.exit_code == 2 + + +@respx.mock +def test_audits_finding_show_json(logged_in, runner): + fid = _FULL_FINDING["id"] + respx.get(f"{BASE}/api/audits/findings/{fid}").mock( + return_value=httpx.Response(200, json=_FULL_FINDING)) + result = runner.invoke(app, ["--json", "audits", "finding", fid]) + assert result.exit_code == 0, result.output + body = json.loads(result.stdout) + assert body["title"] == "retry storm on checkout tool" + assert body["evidence"] == {"sessions": ["run-001"]} + + +@respx.mock +def test_audits_finding_show_human_cards(logged_in, runner): + fid = _FULL_FINDING["id"] + respx.get(f"{BASE}/api/audits/findings/{fid}").mock( + return_value=httpx.Response(200, json=_FULL_FINDING)) + result = runner.invoke(app, ["audits", "finding", fid]) + assert result.exit_code == 0, result.output + out = result.stdout + assert "retry storm on checkout tool" in out and "critical" in out and "failure" in out + assert "seen" in out and "41" in out + assert "analysis" in out and "upstream 502s" in out # root-cause hypothesis + assert "recommendation" in out and "fail fast" in out and "small" in out + assert "evidence" in out and "run-001" in out + + +@respx.mock +def test_audits_finding_not_found_exits_6(logged_in, runner): + fid = "1f5803aa-aaaa-bbbb-cccc-000000000000" + respx.get(f"{BASE}/api/audits/findings/{fid}").mock( + return_value=httpx.Response(404, json={"error": "not found"})) + result = runner.invoke(app, ["audits", "finding", fid]) + assert result.exit_code == 6, result.output + assert "no finding" in result.stderr + assert "HTTP" not in (result.stdout + result.stderr) + + +@respx.mock +def test_audits_finding_malformed_id_is_not_found(logged_in, runner): + # The server's path extractor answers a non-UUID id with a 400 — surface the calm not-found. + respx.get(f"{BASE}/api/audits/findings/not-a-uuid").mock( + return_value=httpx.Response(400, json={"error": "invalid uuid"})) + result = runner.invoke(app, ["--json", "audits", "finding", "not-a-uuid"]) + assert result.exit_code == 6 + assert "no finding" in json.loads(result.stdout)["error"] + + +@respx.mock +def test_audits_finding_forbidden_exits_5(logged_in, runner): + fid = _FULL_FINDING["id"] + respx.get(f"{BASE}/api/audits/findings/{fid}").mock(return_value=httpx.Response( + 403, json={"error": "forbidden", "required_permission": "audits:read"})) + result = runner.invoke(app, ["audits", "finding", fid]) + assert result.exit_code == 5, result.output + assert "audits:read" in result.stderr # the denial names the missing permission + + +@respx.mock +def test_audits_list_forbidden_exits_5(logged_in, runner): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response( + 403, json={"error": "forbidden", "required_permission": "audits:read"})) + result = runner.invoke(app, ["--json", "audits", "list"]) + assert result.exit_code == 5 + assert "audits:read" in json.loads(result.stdout)["error"] + + +# --- audits: finding triage ------------------------------------------------- + + +@respx.mock +def test_finding_ack(logged_in, runner): + fid = _FULL_FINDING["id"] + route = respx.post(f"{BASE}/api/audits/findings/{fid}/status").mock( + return_value=httpx.Response(200, json={"id": fid, "action": "ack", "ok": True})) + result = runner.invoke(app, ["--json", "audits", "ack", fid, "--reason", "known"]) + assert result.exit_code == 0, result.output + body = json.loads(route.calls.last.request.content) + assert body == {"action": "ack", "reason": "known"} + assert json.loads(result.stdout)["ok"] is True + + +@respx.mock +def test_finding_mute(logged_in, runner): + fid = _FULL_FINDING["id"] + route = respx.post(f"{BASE}/api/audits/findings/{fid}/status").mock( + return_value=httpx.Response(200, json={"id": fid, "action": "mute", "ok": True})) + result = runner.invoke(app, ["--json", "audits", "mute", fid, "--reason", "expected", "--yes"]) + assert result.exit_code == 0, result.output + assert json.loads(route.calls.last.request.content)["action"] == "mute" + + +@respx.mock +def test_finding_dismiss(logged_in, runner): + fid = _FULL_FINDING["id"] + route = respx.post(f"{BASE}/api/audits/findings/{fid}/status").mock( + return_value=httpx.Response(200, json={"id": fid, "action": "dismiss", "ok": True})) + result = runner.invoke(app, ["--json", "audits", "dismiss", fid, "--yes"]) + assert result.exit_code == 0, result.output + assert json.loads(route.calls.last.request.content) == {"action": "dismiss"} + + +@respx.mock +def test_finding_resolve_human(logged_in, runner): + fid = _FULL_FINDING["id"] + respx.post(f"{BASE}/api/audits/findings/{fid}/status").mock( + return_value=httpx.Response(200, json={"id": fid, "action": "resolve", "ok": True})) + result = runner.invoke(app, ["audits", "resolve", fid, "--yes"]) + assert result.exit_code == 0, result.output + assert "resolved finding" in result.stderr + + +@respx.mock +def test_finding_reopen(logged_in, runner): + fid = _FULL_FINDING["id"] + route = respx.post(f"{BASE}/api/audits/findings/{fid}/status").mock( + return_value=httpx.Response(200, json={"id": fid, "action": "reopen", "ok": True})) + result = runner.invoke(app, ["audits", "reopen", fid]) + assert result.exit_code == 0, result.output + assert json.loads(route.calls.last.request.content) == {"action": "reopen"} + assert "reopened finding" in result.stderr + + +@respx.mock +def test_finding_assign_sends_assignee(logged_in, runner): + fid = _FULL_FINDING["id"] + route = respx.post(f"{BASE}/api/audits/findings/{fid}/status").mock( + return_value=httpx.Response(200, json={"id": fid, "action": "assign", "ok": True})) + result = runner.invoke(app, ["audits", "assign", fid, "--to", "alice@example.com"]) + assert result.exit_code == 0, result.output + assert json.loads(route.calls.last.request.content) == { + "action": "assign", "assigned_to": "alice@example.com"} + assert "assigned finding" in result.stderr and "alice@example.com" in result.stderr + + +def test_finding_assign_without_to_exits_2(logged_in, runner): + result = runner.invoke(app, ["audits", "assign", _FULL_FINDING["id"]]) + assert result.exit_code == 2 # --to is required + + +def test_finding_unknown_action_exits_2(logged_in, runner): + # The triage verbs are the command surface — an unknown action is a usage error, never a 422. + result = runner.invoke(app, ["audits", "snooze", _FULL_FINDING["id"]]) + assert result.exit_code == 2 + + +def test_triage_action_validator_rejects_unknown(): + # The shared validator behind every triage verb (defence in depth for the --action value). + import pytest + + from fp_cli import _click_compat as click # the Click Typer is running + from fp_cli.commands import audits_cmds + + with pytest.raises(click.BadParameter): + audits_cmds._validate_action("snooze") + assert audits_cmds._validate_action("ack") == "ack" + + +@respx.mock +def test_finding_triage_not_found_exits_6(logged_in, runner): + fid = "1f5803aa-aaaa-bbbb-cccc-000000000000" + respx.post(f"{BASE}/api/audits/findings/{fid}/status").mock( + return_value=httpx.Response(404, json={"error": "not found"})) + result = runner.invoke(app, ["audits", "ack", fid]) + assert result.exit_code == 6, result.output + assert "no finding" in result.stderr + + +@respx.mock +def test_finding_triage_forbidden_exits_5(logged_in, runner): + fid = _FULL_FINDING["id"] + respx.post(f"{BASE}/api/audits/findings/{fid}/status").mock(return_value=httpx.Response( + 403, json={"error": "forbidden", "required_permission": "audits:write"})) + result = runner.invoke(app, ["--json", "audits", "ack", fid]) + assert result.exit_code == 5 + assert "audits:write" in json.loads(result.stdout)["error"] + + +# --- audits: reference context --- +# +# This surface shipped with zero tests, and the first thing an operator does +# after `context-set` is the thing that crashed: `context-show` dereferenced a +# theme token that does not exist, so every human-mode render raised +# AttributeError. `--json` returns before the formatter, which is why nothing +# scripted noticed. The human-render assertions below are the ones that matter. + +_DOC_URL = "https://docs.example.com/runbook" + +_CTX_PENDING = { + "text": "checkout agent for a retail store", + "sources": [ + {"id": "s1", "url": _DOC_URL, "position": 0, + "status": "pending", "final_url": None, "title": None, "content_type": None, + "preview": "", "preview_truncated": False, "chars": 0, "chars_total": 0, + "truncated": False, "redactions": 0, "injection_markers": [], + "error_code": None, "error_detail": None, "fetched_at": None, + "attempted_at": None, "changed_at": None}, + ], +} + + +@respx.mock +def test_context_show_renders_a_pending_source(logged_in, runner): + """The regression test for the AttributeError: a non-`ok` status took the + branch that referenced a missing theme token.""" + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + respx.get(f"{BASE}/api/audits/au1/context").mock( + return_value=httpx.Response(200, json=_CTX_PENDING)) + result = runner.invoke(app, ["audits", "context-show", "nightly-prod"]) + assert result.exit_code == 0, result.output + assert "pending" in result.stderr + assert "docs.example.com" in result.stderr + + +@respx.mock +def test_context_show_renders_an_ok_source_with_injection_markers(logged_in, runner): + """The other crashing branch: a stored page that carries markers — i.e. the + exact case the feature exists to surface.""" + ctx = json.loads(json.dumps(_CTX_PENDING)) + ctx["sources"][0].update(status="ok", chars=1200, chars_total=1200, + injection_markers=["ignore previous instructions"]) + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + respx.get(f"{BASE}/api/audits/au1/context").mock(return_value=httpx.Response(200, json=ctx)) + result = runner.invoke(app, ["audits", "context-show", "nightly-prod"]) + assert result.exit_code == 0, result.output + assert "review" in result.stderr + assert "instructions to an AI" in result.stderr + + +@respx.mock +def test_context_show_tolerates_an_unknown_status(logged_in, runner): + """The server owns this vocabulary; a value we do not know must render, not raise.""" + ctx = json.loads(json.dumps(_CTX_PENDING)) + ctx["sources"][0]["status"] = "quarantined" + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + respx.get(f"{BASE}/api/audits/au1/context").mock(return_value=httpx.Response(200, json=ctx)) + result = runner.invoke(app, ["audits", "context-show", "nightly-prod"]) + assert result.exit_code == 0, result.output + assert "quarantined" in result.stderr + + +@respx.mock +def test_context_show_marks_a_retained_snapshot_as_still_used(logged_in, runner): + """A `failed` re-read does not withdraw the snapshot we already hold — the + server still puts it in the prompt. Reporting it as simply "failed" told the + operator the opposite, and suppressed its injection markers with it.""" + ctx = json.loads(json.dumps(_CTX_PENDING)) + ctx["sources"][0].update(status="failed", chars=1500, chars_total=1500, + error_detail="connection reset", + injection_markers=["ignore previous instructions"]) + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + respx.get(f"{BASE}/api/audits/au1/context").mock(return_value=httpx.Response(200, json=ctx)) + result = runner.invoke(app, ["audits", "context-show", "nightly-prod"]) + assert result.exit_code == 0, result.output + assert "review" in result.stderr, "a retained page with markers must still say review" + assert "still used" in result.stderr + assert "1500 chars" in result.stderr + assert "instructions to an AI" in result.stderr + + +@respx.mock +def test_context_show_does_not_claim_a_blocked_page_is_used(logged_in, runner): + """`blocked` is the one status that overrides a retained snapshot: the guard + refused the URL, so it is not in the prompt and must not read as though it is.""" + ctx = json.loads(json.dumps(_CTX_PENDING)) + ctx["sources"][0].update(status="blocked", chars=900, error_detail="private address") + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + respx.get(f"{BASE}/api/audits/au1/context").mock(return_value=httpx.Response(200, json=ctx)) + result = runner.invoke(app, ["audits", "context-show", "nightly-prod"]) + assert result.exit_code == 0, result.output + assert "blocked" in result.stderr + assert "still used" not in result.stderr + assert "private address" in result.stderr + + +@respx.mock +def test_context_set_text_only_preserves_existing_urls(logged_in, runner): + """`--text` alone used to send `"urls": []`, deleting every reference page and + its snapshot — while the brief was read-merged three lines away.""" + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + respx.get(f"{BASE}/api/audits/au1/context").mock( + return_value=httpx.Response(200, json=_CTX_PENDING)) + put = respx.put(f"{BASE}/api/audits/au1/context").mock( + return_value=httpx.Response(200, json={"id": "au1", "sources": 1, "queued": 0})) + result = runner.invoke(app, ["--json", "audits", "context-set", "nightly-prod", + "--text", "new brief"]) + assert result.exit_code == 0, result.output + sent = json.loads(put.calls[0].request.content) + assert sent["text"] == "new brief" + assert sent["urls"] == [_DOC_URL], "URLs must survive a --text edit" + + +@respx.mock +def test_context_set_url_only_preserves_the_brief(logged_in, runner): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + respx.get(f"{BASE}/api/audits/au1/context").mock( + return_value=httpx.Response(200, json=_CTX_PENDING)) + put = respx.put(f"{BASE}/api/audits/au1/context").mock( + return_value=httpx.Response(200, json={"id": "au1", "sources": 1, "queued": 1})) + other = "https://docs.example.com/other" + result = runner.invoke(app, ["--json", "audits", "context-set", "nightly-prod", + "--url", other]) + assert result.exit_code == 0, result.output + sent = json.loads(put.calls[0].request.content) + assert sent["text"] == _CTX_PENDING["text"] + assert sent["urls"] == [other] + + +@respx.mock +def test_context_set_clear_urls_sends_an_empty_list(logged_in, runner): + """Removal has to stay expressible — it is now explicit rather than implied.""" + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + respx.get(f"{BASE}/api/audits/au1/context").mock( + return_value=httpx.Response(200, json=_CTX_PENDING)) + put = respx.put(f"{BASE}/api/audits/au1/context").mock( + return_value=httpx.Response(200, json={"id": "au1", "sources": 0, "queued": 0})) + result = runner.invoke(app, ["--json", "audits", "context-set", "nightly-prod", + "--clear-urls"]) + assert result.exit_code == 0, result.output + assert json.loads(put.calls[0].request.content)["urls"] == [] + + +@respx.mock +def test_context_set_rejects_url_and_clear_urls_together(logged_in, runner): + result = runner.invoke(app, ["--json", "audits", "context-set", "nightly-prod", + "--url", _DOC_URL, "--clear-urls"]) + assert result.exit_code == 2, result.output + + +# --- audits: reference-context telemetry --- +# +# These pin the emitted PROPERTY NAMES, which nothing else does: the CLI sent this +# count as `count` while the dashboard sent `url_count`, so one event carried two +# names and neither answered "how much context is being attached?" on its own. +# They assert what actually leaves `record_action`, because it filters through the +# `_SAFE_PROP_KEYS` allowlist (`commands/_write.py`) first — a property missing from +# that list is dropped with no error, so a rename that forgets its allowlist entry +# reads as correct at the call site and silently emits nothing. + + +@pytest.fixture +def emitted(monkeypatch): + """Every ``(event, properties)`` the command sends, as the allowlist leaves it.""" + events = [] + monkeypatch.setattr( + analytics, "capture", + lambda event, properties=None: events.append((event, properties or {})), + ) + return events + + +def _props(emitted, event: str) -> dict: + """The one ``event``'s properties — failing if it did not fire exactly once.""" + matches = [props for name, props in emitted if name == event] + assert len(matches) == 1, f"expected one {event}, got {[name for name, _ in emitted]}" + return matches[0] + + +@respx.mock +def test_context_set_emits_url_count(logged_in, runner, emitted): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + respx.get(f"{BASE}/api/audits/au1/context").mock( + return_value=httpx.Response(200, json=_CTX_PENDING)) + respx.put(f"{BASE}/api/audits/au1/context").mock( + return_value=httpx.Response(200, json={"id": "au1", "sources": 2, "queued": 2})) + result = runner.invoke(app, ["--json", "audits", "context-set", "nightly-prod", + "--url", _DOC_URL, "--url", "https://docs.example.com/slo"]) + assert result.exit_code == 0, result.output + props = _props(emitted, "audit_context_saved") + assert props["url_count"] == 2 + assert "count" not in props, "the dashboard's name for this is url_count — one series, one name" + assert props["via"] == "cli" + + +@respx.mock +def test_audits_create_with_context_emits_url_count(logged_in, runner, emitted): + """The second call site of the same event: context attached at creation.""" + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[])) + respx.post(f"{BASE}/api/audits").mock( + return_value=httpx.Response(201, json={"id": "au9", "created_at": "t", "sources": 1})) + result = runner.invoke(app, ["--json", "audits", "create", "nightly-prod", + "--text", "checkout agent", "--url", _DOC_URL]) + assert result.exit_code == 0, result.output + props = _props(emitted, "audit_context_saved") + assert props["url_count"] == 1 and "count" not in props + + +@respx.mock +def test_context_refresh_emits_url_count(logged_in, runner, emitted): + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + respx.post(f"{BASE}/api/audits/au1/context/refresh").mock( + return_value=httpx.Response(200, json={"queued": 3, "skipped": 1})) + result = runner.invoke(app, ["--json", "audits", "context-refresh", "nightly-prod"]) + assert result.exit_code == 0, result.output + props = _props(emitted, "audit_context_refreshed") + assert props["url_count"] == 3 and "count" not in props + + +@respx.mock +def test_edit_from_show_json_round_trips(logged_in, runner, tmp_path): + """`audits show --json` emits every field, including the two the definition + endpoint 422s. Feeding that straight back is the documented workflow, so the + read-only keys are stripped client-side.""" + shown = dict(_FULL_AUDIT, additional_context="a brief", reference_url_count=2, run_count=9) + f = tmp_path / "audit.json" + f.write_text(json.dumps(shown)) + respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT])) + put = respx.put(f"{BASE}/api/audits/au1").mock( + return_value=httpx.Response(200, json=_FULL_AUDIT)) + result = runner.invoke(app, ["--json", "audits", "edit", "nightly-prod", "--file", str(f)]) + assert result.exit_code == 0, result.output + sent = json.loads(put.calls[0].request.content) + for banned in ("additional_context", "reference_url_count", "run_count", "id", "open_findings"): + assert banned not in sent, f"{banned} must not reach the definition endpoint" + assert sent["name"] == "nightly-prod" diff --git a/fp-cli/tests/test_auth.py b/fp-cli/tests/test_auth.py new file mode 100644 index 000000000..9b890eedd --- /dev/null +++ b/fp-cli/tests/test_auth.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone + +import httpx +import pytest +import respx + +from fp_cli import auth, config +from fp_cli.errors import ApiError, AuthError, NetworkError + +BASE = "http://dash.test" + + +@respx.mock +def test_request_otp_ok(): + route = respx.post(f"{BASE}/api/auth/otp/request").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + auth.request_otp(BASE, "me@test") + assert route.called + assert json.loads(route.calls.last.request.read()) == {"email": "me@test"} + # Marks a CLI login so the server emails the paste-into-terminal OTP variant. + assert route.calls.last.request.headers["X-AgentEye-Client"] == "cli" + + +@respx.mock +def test_request_otp_server_error_raises(): + respx.post(f"{BASE}/api/auth/otp/request").mock(return_value=httpx.Response(502)) + with pytest.raises(ApiError): + auth.request_otp(BASE, "me@test") + + +@respx.mock +def test_verify_otp_reads_token_from_set_cookie_not_body(): + # The dashboard returns user + expires_in_secs in the body and the token only + # in the Set-Cookie header — verify we read it from the cookie. + respx.post(f"{BASE}/api/auth/otp/verify").mock( + return_value=httpx.Response( + 200, + json={"user": {"id": "u1", "email": "me@test"}, "expires_in_secs": 3600}, + headers={"set-cookie": "ae_session=tok-xyz; HttpOnly; Path=/; Max-Age=3600"}, + ) + ) + token, expires_in, user = auth.verify_otp(BASE, "me@test", "123456") + assert token == "tok-xyz" + assert expires_in == 3600 + assert user["email"] == "me@test" + + +@respx.mock +def test_verify_otp_wrong_code_is_auth_error(): + respx.post(f"{BASE}/api/auth/otp/verify").mock(return_value=httpx.Response(401, json={})) + with pytest.raises(AuthError): + auth.verify_otp(BASE, "me@test", "000000") + + +@respx.mock +def test_verify_otp_without_cookie_is_auth_error(): + respx.post(f"{BASE}/api/auth/otp/verify").mock( + return_value=httpx.Response(200, json={"user": {}, "expires_in_secs": 3600}) + ) + with pytest.raises(AuthError): + auth.verify_otp(BASE, "me@test", "123456") + + +@respx.mock +def test_verify_otp_network_error(): + respx.post(f"{BASE}/api/auth/otp/verify").mock(side_effect=httpx.ConnectError("down")) + with pytest.raises(NetworkError): + auth.verify_otp(BASE, "me@test", "123456") + + +def test_persist_session_computes_expiry_and_writes_0600(home): + cfg = config.CliConfig() + now = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc) + auth.persist_session(cfg, BASE, "tok", 3600, {"email": "me@test", "id": "u1"}, now=now) + + reloaded = config.load_config() + assert reloaded.session_token == "tok" + assert reloaded.expires_at == "2026-05-25T13:00:00Z" + assert reloaded.email == "me@test" + assert reloaded.user_id == "u1" + assert reloaded.base_url == BASE + assert not config.is_expired(reloaded, now=now) + + +@respx.mock +def test_logout_is_best_effort_on_network_error(): + respx.post(f"{BASE}/api/auth/logout").mock(side_effect=httpx.ConnectError("down")) + # Must not raise even though the server is unreachable. + auth.logout(BASE, "tok") + + +def test_logout_noop_without_token(): + # No registered routes — if it tried to call out, respx would complain. + auth.logout(BASE, None) diff --git a/fp-cli/tests/test_auth_mode.py b/fp-cli/tests/test_auth_mode.py new file mode 100644 index 000000000..d50f3552c --- /dev/null +++ b/fp-cli/tests/test_auth_mode.py @@ -0,0 +1,272 @@ +"""API-key auth mode: precedence, the empty-string rule, what goes on the wire, and +what never touches disk. + +The wire assertions here are deliberately NEGATIVE as well as positive. "The bearer +header is set" passes just as happily when the CLI *also* attaches the operator's +`ae_session` cookie — which would hand a human's session to `/v1` from a CI box, and +no positive-only test would ever notice. +""" + +from __future__ import annotations + +import json + +import httpx +import pytest +import respx + +from fp_cli import config +from fp_cli._context import AppState, AuthMode, resolve_auth +from fp_cli.app import app + +BASE = "http://dash.test" +KEY = "ak_live_abc123" + + +def _mock_keys(mock): + """Mock BOTH surfaces so a wrong-mode request is a wrong URL, not a network error.""" + return ( + mock.get(f"{BASE}/api/keys").mock(return_value=httpx.Response(200, json=[])), + mock.get(f"{BASE}/v1/keys").mock(return_value=httpx.Response(200, json=[])), + ) + + +def _run_keys_list(runner, argv, env=None): + """Run `keys list` with both surfaces mocked → (result, api_route, v1_route).""" + with respx.mock(assert_all_called=False) as mock: + api_route, v1_route = _mock_keys(mock) + result = runner.invoke(app, [*argv, "--json", "keys", "list"], env=env or {}) + return result, api_route, v1_route + + +# --- precedence ladder ------------------------------------------------------ + + +def test_api_key_flag_selects_key_mode(home, runner): + result, api_route, v1_route = _run_keys_list(runner, ["--base-url", BASE, "--api-key", KEY]) + assert result.exit_code == 0, result.output + assert v1_route.called and not api_route.called + + +def test_token_flag_beats_api_key_env(home, runner): + # An explicit flag outranks any env var, including the key's. + result, api_route, v1_route = _run_keys_list( + runner, ["--base-url", BASE, "--token", "tok"], env={"FP_API_KEY": KEY} + ) + assert result.exit_code == 0, result.output + assert api_route.called and not v1_route.called + + +def test_api_key_env_beats_token_env(home, runner): + # Between two env vars the KEY wins — the documented rung. + result, api_route, v1_route = _run_keys_list( + runner, + ["--base-url", BASE], + env={"FP_API_KEY": KEY, "FP_TOKEN": "tok"}, + ) + assert result.exit_code == 0, result.output + assert v1_route.called and not api_route.called + + +def test_token_env_beats_saved_session(home, runner): + config.save_config(config.CliConfig(base_url=BASE, session_token="saved-tok", + expires_at="2999-01-01T00:00:00Z")) + with respx.mock(assert_all_called=False) as mock: + api_route, _v1 = _mock_keys(mock) + result = runner.invoke(app, ["--json", "keys", "list"], env={"FP_TOKEN": "env-tok"}) + assert result.exit_code == 0, result.output + assert "ae_session=env-tok" in api_route.calls.last.request.headers.get("cookie", "") + + +def test_api_key_env_beats_saved_session(home, runner): + config.save_config(config.CliConfig(base_url=BASE, session_token="saved-tok", + expires_at="2999-01-01T00:00:00Z")) + result, api_route, v1_route = _run_keys_list( + runner, [], env={"FP_API_KEY": KEY} + ) + assert result.exit_code == 0, result.output + assert v1_route.called and not api_route.called + + +def test_saved_session_when_nothing_else(logged_in, runner): + result, api_route, v1_route = _run_keys_list(runner, []) + assert result.exit_code == 0, result.output + assert api_route.called and not v1_route.called + + +def test_no_credential_at_all_exits_4(home, runner): + with respx.mock(assert_all_called=False) as mock: + catch_all = mock.route().mock(return_value=httpx.Response(200, json=[])) + result = runner.invoke(app, ["--base-url", BASE, "keys", "list"]) + assert result.exit_code == 4, result.output + assert catch_all.call_count == 0 # never opened a connection + + +# --- resolve_auth, unit ------------------------------------------------------ + + +@pytest.mark.parametrize( + "kwargs,expected", + [ + # flag key, flag token, env key, env token, saved token + (dict(api_key=KEY, api_key_on_cli=True, token=None, token_on_cli=False, saved_token=None), + (AuthMode.API_KEY, KEY, None)), + (dict(api_key=None, api_key_on_cli=False, token="t", token_on_cli=True, saved_token=None), + (AuthMode.SESSION, None, "t")), + (dict(api_key=KEY, api_key_on_cli=False, token="t", token_on_cli=False, saved_token=None), + (AuthMode.API_KEY, KEY, None)), + (dict(api_key=None, api_key_on_cli=False, token="t", token_on_cli=False, saved_token="s"), + (AuthMode.SESSION, None, "t")), + (dict(api_key=None, api_key_on_cli=False, token=None, token_on_cli=False, saved_token="s"), + (AuthMode.SESSION, None, "s")), + (dict(api_key=None, api_key_on_cli=False, token=None, token_on_cli=False, saved_token=None), + (AuthMode.NONE, None, None)), + # `--api-key ""` is key mode with NO credential — never a fallback to the + # saved session (mirrors the established `--token ""` rule). + (dict(api_key="", api_key_on_cli=True, token=None, token_on_cli=False, saved_token="s"), + (AuthMode.API_KEY, "", None)), + ], +) +def test_resolve_auth_precedence(kwargs, expected): + assert resolve_auth(**kwargs) == expected + + +def test_both_flags_is_a_usage_error(): + from fp_cli import _click_compat as click + + with pytest.raises(click.UsageError): + resolve_auth(api_key=KEY, api_key_on_cli=True, token="t", token_on_cli=True, + saved_token=None) + + +def test_both_flags_exits_2_with_zero_http_calls(logged_in, runner): + with respx.mock(assert_all_called=False) as mock: + catch_all = mock.route().mock(return_value=httpx.Response(200, json=[])) + result = runner.invoke( + app, ["--base-url", BASE, "--api-key", KEY, "--token", "tok", "keys", "list"] + ) + assert result.exit_code == 2, result.output + assert catch_all.call_count == 0 # never guessed which one you meant + + +# --- the empty-string rule --------------------------------------------------- + + +def test_empty_api_key_does_not_fall_back_to_saved_session(logged_in, runner): + """`--api-key ""` (an unset CI var) must not silently act as the logged-in human.""" + with respx.mock(assert_all_called=False) as mock: + catch_all = mock.route().mock(return_value=httpx.Response(200, json=[])) + result = runner.invoke(app, ["--base-url", BASE, "--api-key", "", "keys", "list"]) + assert result.exit_code == 4, result.output # key mode, no credential + assert catch_all.call_count == 0 + + +# --- what goes on the wire (both directions) --------------------------------- + + +def test_key_mode_sends_bearer_and_no_cookie(logged_in, runner): + # `logged_in` seeds a saved session on purpose: the cookie is available, and must + # still not be sent. + result, _api, v1_route = _run_keys_list(runner, ["--base-url", BASE, "--api-key", KEY]) + assert result.exit_code == 0, result.output + headers = v1_route.calls.last.request.headers + assert headers["authorization"] == f"Bearer {KEY}" + assert "cookie" not in headers, "an API-key request must never carry ae_session" + + +def test_session_mode_sends_cookie_and_no_bearer(logged_in, runner): + result, api_route, _v1 = _run_keys_list(runner, []) + assert result.exit_code == 0, result.output + headers = api_route.calls.last.request.headers + assert "ae_session=tok" in headers.get("cookie", "") + assert "authorization" not in headers + + +# --- the org header ---------------------------------------------------------- + + +def test_key_mode_never_sends_the_saved_org(home, runner): + config.save_config(config.CliConfig(base_url=BASE, org="human-org")) + result, _api, v1_route = _run_keys_list(runner, ["--api-key", KEY]) + assert result.exit_code == 0, result.output + assert "x-agenteye-org" not in v1_route.calls.last.request.headers + + +def test_key_mode_sends_an_explicit_org(home, runner): + config.save_config(config.CliConfig(base_url=BASE, org="human-org")) + result, _api, v1_route = _run_keys_list(runner, ["--api-key", KEY, "--org", "acme"]) + assert result.exit_code == 0, result.output + assert v1_route.calls.last.request.headers["x-agenteye-org"] == "acme" + + +def test_session_mode_still_sends_the_saved_org(home, runner): + config.save_config(config.CliConfig(base_url=BASE, session_token="tok", + expires_at="2999-01-01T00:00:00Z", org="human-org")) + result, api_route, _v1 = _run_keys_list(runner, []) + assert result.exit_code == 0, result.output + assert api_route.calls.last.request.headers["x-agenteye-org"] == "human-org" + + +# --- the key never reaches disk --------------------------------------------- + + +def test_api_key_is_never_persisted(home, runner): + result, _api, _v1 = _run_keys_list(runner, ["--base-url", BASE, "--api-key", KEY]) + assert result.exit_code == 0, result.output + path = config.config_path() + on_disk = path.read_text() if path.exists() else "" + assert KEY not in on_disk + # And no field quietly holds it either (a new CliConfig field would be the way + # this regresses). + assert KEY not in json.dumps(config.load_config().__dict__) + + +def test_api_key_is_not_persisted_by_whoami(home, runner): + result = runner.invoke(app, ["--base-url", BASE, "--api-key", KEY, "--json", "whoami"]) + assert result.exit_code == 0, result.output + path = config.config_path() + assert KEY not in (path.read_text() if path.exists() else "") + + +# --- whoami in key mode is the documented exception -------------------------- + + +def test_whoami_key_mode_exits_0_with_honest_shape(logged_in, runner): + with respx.mock(assert_all_called=False) as mock: + catch_all = mock.route().mock(return_value=httpx.Response(200, json={})) + result = runner.invoke( + app, ["--base-url", BASE, "--api-key", KEY, "--org", "acme", "--json", "whoami"] + ) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == { + "logged_in": False, + "auth_mode": "api_key", + "active_org": "acme", + } + assert catch_all.call_count == 0 # a key has no identity to look up + + +def test_whoami_key_mode_reports_no_org_when_none_given(home, runner): + config.save_config(config.CliConfig(base_url=BASE, org="human-org")) + result = runner.invoke(app, ["--api-key", KEY, "--json", "whoami"]) + assert result.exit_code == 0, result.output + # The SAVED org is not the key's org, so it must not be reported as active. + assert json.loads(result.stdout)["active_org"] is None + + +def test_whoami_human_shapes_carry_auth_mode(home, runner): + result = runner.invoke(app, ["--base-url", BASE, "--json", "whoami"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == {"logged_in": False, "auth_mode": "none"} + + +# --- AppState default -------------------------------------------------------- + + +def test_appstate_defaults_to_none_mode(): + # An AppState built by another path (tests, embedders) must never be silently + # treated as key mode. + state = AppState(json=False, base_url=None, token=None, timeout=30.0, + config=config.CliConfig()) + assert state.auth_mode is AuthMode.NONE + assert state.api_key is None diff --git a/fp-cli/tests/test_click_compat.py b/fp-cli/tests/test_click_compat.py new file mode 100644 index 000000000..488bd8422 --- /dev/null +++ b/fp-cli/tests/test_click_compat.py @@ -0,0 +1,118 @@ +"""Guard the contract that ``fp_cli/_click_compat.py`` exists to hold. + +Typer 0.26 vendored Click and now catches only *its* Click's exceptions. Every way +that coupling breaks is silent — the CLI imports, compiles, and passes every happy +path while typed errors exit 1 with an empty stderr and the telemetry flag catalog +quietly empties. These are the alarms. +""" + +from __future__ import annotations + +import ast +import pathlib + +import typer +from typer.testing import CliRunner + +from fp_cli import _click_compat, analytics_registry +from fp_cli.errors import ForbiddenError, KeyModeUnsupportedError, NotFoundError + +_PACKAGE = pathlib.Path(__file__).resolve().parent.parent / "fp_cli" + + +def test_the_package_scan_actually_has_something_to_scan(): + """`_PACKAGE` is a path literal, so a package rename makes it point at nothing. + + Every scanning test below then walks an empty directory, finds zero violations and + passes — the guard silently stops guarding. This is the tripwire for that: it caught + nothing during the agenteye -> fp_cli rename only because the literal was updated in + the same pass, which is exactly the kind of thing that is remembered once. + """ + assert _PACKAGE.is_dir(), f"{_PACKAGE} does not exist — the scanning tests are vacuous" + modules = list(_PACKAGE.rglob("*.py")) + assert len(modules) > 20, ( + f"only {len(modules)} modules found under {_PACKAGE}; the scan is not seeing the package" + ) + + +def _click_imports(path: pathlib.Path) -> list[str]: + """Every direct `import click` / `from click import …` in one module.""" + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + hits = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + hits += [a.name for a in node.names if a.name == "click" or a.name.startswith("click.")] + elif isinstance(node, ast.ImportFrom): + if node.module and (node.module == "click" or node.module.startswith("click.")): + hits.append(node.module) + return hits + + +def test_package_never_imports_click_directly(): + """The pip `click` distribution is not necessarily the Click Typer is running. + + Binding to it directly is the whole bug: `raise click.UsageError(...)` from the wrong + Click is not caught by Typer's runner, so a clean exit-2 usage error becomes exit 1 with + nothing on stderr. `_click_compat` is the only module allowed to name `click`. + """ + offenders = { + str(path.relative_to(_PACKAGE)): names + for path in sorted(_PACKAGE.rglob("*.py")) + if path.name != "_click_compat.py" and (names := _click_imports(path)) + } + assert not offenders, ( + f"import Click via `from . import _click_compat as click`, not directly: {offenders}" + ) + + +def test_typed_errors_reach_typers_handler(): + """A typed error must still be *caught* by Typer: its message on stderr, its exit code. + + Asserted through a real command run rather than an isinstance check, because what matters + is the runner's behaviour — which is what silently changed under typer 0.26. + """ + app = typer.Typer() + + @app.command() + def boom() -> None: + raise ForbiddenError("nope", hint="ask an admin") + + result = CliRunner().invoke(app, []) + assert result.exit_code == 5, result.output # not 1: uncaught would collapse to 1 + assert "nope" in result.output + + +def test_error_exit_codes_are_distinct_per_class(): + """The exit-code contract is documented in `--help` and scripted against.""" + assert ForbiddenError("x").exit_code == 5 + assert NotFoundError("x").exit_code == 6 + # Key mode reuses exit 2 rather than adding a seventh code — the table is a scripted + # contract restated in `app.py`, `cli/skill/SKILL.md` and `enterprise-docs/cli.md`. + assert KeyModeUnsupportedError("x").exit_code == 2 + assert issubclass(ForbiddenError, _click_compat.ClickException) + # Through the shim, not pip Click: bind it to the wrong Click and every key-mode + # refusal escapes uncaught as exit 1 with an empty stderr. + assert issubclass(KeyModeUnsupportedError, _click_compat.ClickException) + + +def test_is_option_recognises_typer_options(): + """Typer's vendored Click has no `Option` class, so `isinstance` cannot answer this. + + If a future Click stops setting `param_type_name`, this fails loudly instead of quietly + dropping every flag from the telemetry catalog. + """ + from typer.main import get_command + + from fp_cli.app import app as real_app + + params = get_command(real_app).params + assert params, "the root command should declare the global options" + assert any(_click_compat.is_option(p) for p in params) + assert all(not _click_compat.is_option(p) for p in params if p.param_type_name == "argument") + + +def test_flag_catalog_is_populated(): + """The catalog is derived by walking the real command tree; empty means the walk broke.""" + _known, _leaves, flags, value_flags = analytics_registry.build() + assert "--json" in flags and "--base-url" in value_flags + assert len(flags) > 50, f"flag catalog collapsed to {len(flags)} entries" diff --git a/fp-cli/tests/test_client.py b/fp-cli/tests/test_client.py new file mode 100644 index 000000000..874200df0 --- /dev/null +++ b/fp-cli/tests/test_client.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +import httpx +import pytest +import respx + +from fp_cli import client as api +from fp_cli.client import AuthMode, ClientContext +from fp_cli.errors import ( + ApiError, + AuthError, + ForbiddenError, + NetworkError, + NotFoundError, +) +from fp_cli.models import Page + +BASE = "http://dash.test" + + +def ctx() -> ClientContext: + return ClientContext(base_url=BASE, token="tok") + + +def key_ctx() -> ClientContext: + return ClientContext(base_url=BASE, api_key="ak_test", auth_mode=AuthMode.API_KEY) + + +@respx.mock +def test_list_events_maps_params_and_parses(): + route = respx.get(f"{BASE}/api/events").mock( + return_value=httpx.Response( + 200, + json={ + "events": [ + { + "id": 2, + "session_id": "s", + "agent_id": "a", + "event_type": "tool_use", + "ts": "2026-05-25T00:00:00Z", + "payload": {"k": 1}, + "environment": "prod", + } + ], + "next_cursor": 1, + }, + ) + ) + page = api.list_events( + ctx(), + session_id="s", + event_type=["a", "b"], + environment=["prod", "dev"], + limit=10, + ) + assert isinstance(page, Page) + assert page.next_cursor == 1 + assert page.items[0].event_type == "tool_use" + assert page.items[0].payload == {"k": 1} + + request = route.calls.last.request + assert request.url.params["session_id"] == "s" + assert request.url.params["event_type"] == "a,b" + assert request.url.params["environment"] == "prod,dev" + assert request.url.params["limit"] == "10" + # cookie auth + request id propagation + assert "ae_session=tok" in request.headers.get("cookie", "") + assert request.headers.get("x-request-id") + + +@respx.mock +def test_list_event_summaries_hits_light_feed_and_parses_summary(): + # The light feed: same params/cursor as list_events, but payload-free rows carrying the + # server-computed summary/is_error + promoted columns. + route = respx.get(f"{BASE}/api/events/summary").mock( + return_value=httpx.Response( + 200, + json={ + "events": [ + { + "id": 7, + "session_id": "s", + "agent_id": "a", + "event_type": "error", + "ts": "2026-05-25T00:00:00Z", + "environment": "prod", + "summary": "TimeoutError: upstream timed out", + "is_error": True, + "error_type": "TimeoutError", + "output_tokens": None, + "context_window": 200000, + "context_fill": 12.5, + } + ], + "next_cursor": "2026-05-25 00:00:00.000000|7", + }, + ) + ) + page = api.list_event_summaries( + ctx(), + errored=True, + error_type="TimeoutError", + environment=["prod", "dev"], + limit=10, + ) + assert isinstance(page, Page) + assert page.next_cursor == "2026-05-25 00:00:00.000000|7" # interchangeable string cursor + e = page.items[0] + assert e.event_type == "error" + assert e.summary == "TimeoutError: upstream timed out" # server field, no payload parsing + assert e.is_error is True and e.error_type == "TimeoutError" + assert e.context_window == 200000 and e.context_fill == 12.5 + assert e.payload == {} # the fat column is never fetched on this feed + + params = route.calls.last.request.url.params + assert params["errored"] == "true" + assert params["error_type"] == "TimeoutError" + assert params["environment"] == "prod,dev" + + +@respx.mock +def test_list_evaluations_score_filters_and_latest(): + route = respx.get(f"{BASE}/api/evaluations").mock( + return_value=httpx.Response(200, json={"evaluations": [], "next_cursor": None}) + ) + api.list_evaluations( + ctx(), + score_filters="helpfulness:0.5..0.8", + latest_per_session=True, + status="done", + ) + params = route.calls.last.request.url.params + assert params["score_filters"] == "helpfulness:0.5..0.8" + assert params["latest_per_session"] == "true" + assert params["status"] == "done" + + +@respx.mock +def test_401_maps_to_auth_error(): + respx.get(f"{BASE}/api/auth/session").mock(return_value=httpx.Response(401, json={})) + with pytest.raises(AuthError): + api.get_session_user(ctx()) + + +@respx.mock +def test_403_maps_to_forbidden(): + respx.get(f"{BASE}/api/events").mock( + return_value=httpx.Response(403, json={"error": "forbidden"}) + ) + with pytest.raises(ForbiddenError): + api.list_events(ctx()) + + +@respx.mock +def test_404_maps_to_not_found(): + respx.get(f"{BASE}/api/events").mock( + return_value=httpx.Response(404, json={"error": "nope"}) + ) + with pytest.raises(NotFoundError): + api.list_events(ctx()) + + +@respx.mock +def test_500_carries_status_and_request_id(): + respx.get(f"{BASE}/api/events").mock( + return_value=httpx.Response(500, json={"error": "boom"}, headers={"x-request-id": "rid-1"}) + ) + with pytest.raises(ApiError) as excinfo: + api.list_events(ctx()) + assert excinfo.value.status == 500 + assert excinfo.value.request_id == "rid-1" + + +@respx.mock +def test_network_error(): + respx.get(f"{BASE}/api/events").mock(side_effect=httpx.ConnectError("down")) + with pytest.raises(NetworkError): + api.list_events(ctx()) + + +# --- API-key mode: the same statuses, but the message has to name the key ---- + + +@respx.mock +def test_key_mode_401_blames_the_key_not_a_session(): + respx.get(f"{BASE}/v1/events").mock(return_value=httpx.Response(401, json={})) + with pytest.raises(AuthError) as excinfo: + api.list_events(key_ctx()) + message = str(excinfo.value) + assert "API key" in message + # "Run fp login" is exactly the wrong advice for a CI job holding a key. + assert "login" not in message + + +@respx.mock +def test_key_mode_403_names_the_permission_and_the_org_ambiguity(): + respx.get(f"{BASE}/v1/events").mock( + return_value=httpx.Response( + 403, json={"error": "forbidden", "required_permission": "events:read"} + ) + ) + with pytest.raises(ForbiddenError) as excinfo: + api.list_events(key_ctx()) + message = str(excinfo.value) + assert "events:read" in message + # The server answers 403 for "wrong org" too — deliberately, so a key holder + # cannot enumerate orgs — so the CLI must not present one cause as the answer. + assert "org" in message + + +@respx.mock +def test_key_mode_403_with_an_empty_body_still_explains_both_causes(): + # No JSON at all: `_extract_error` and `_required_permission` both come back + # empty, which is the shape a proxy or a non-permission 403 produces. + respx.get(f"{BASE}/v1/events").mock(return_value=httpx.Response(403, text="")) + with pytest.raises(ForbiddenError) as excinfo: + api.list_events(key_ctx()) + message = str(excinfo.value) + assert "API key" in message and "org" in message + + +@respx.mock +def test_session_mode_403_wording_is_unchanged(): + respx.get(f"{BASE}/api/events").mock( + return_value=httpx.Response(403, json={"required_permission": "events:read"}) + ) + with pytest.raises(ForbiddenError) as excinfo: + api.list_events(ctx()) + assert str(excinfo.value) == "you don't have the events:read permission" + + +@respx.mock +def test_key_mode_redirect_to_login_names_the_real_problem(): + # httpx does not follow redirects, so without the explicit 3xx check this returns + # an empty body and surfaces as "the dashboard returned a malformed response" — + # which sends people looking for a server bug instead of a routing one. + respx.get(f"{BASE}/v1/events").mock( + return_value=httpx.Response(307, headers={"location": "/login?next=%2Fv1%2Fevents"}) + ) + with pytest.raises(ApiError) as excinfo: + api.list_events(key_ctx()) + assert "/v1 is not routed" in str(excinfo.value) + assert "localhost:8080" in (excinfo.value.hint or "") + + +@respx.mock +def test_key_mode_ordinary_2xx_is_untouched_by_the_redirect_check(): + respx.get(f"{BASE}/v1/keys").mock(return_value=httpx.Response(200, json=[])) + assert api.list_keys(key_ctx()) == [] + + +def test_paginate_walks_until_null_cursor(): + pages = [Page(items=[1, 2], next_cursor=10), Page(items=[3], next_cursor=None)] + seen_cursors = [] + + def fetch(cursor, limit): + seen_cursors.append(cursor) + return pages.pop(0) + + assert list(api.paginate(fetch)) == [1, 2, 3] + assert seen_cursors == [None, 10] + + +def test_paginate_respects_limit(): + def fetch(cursor, limit): + start = cursor if cursor is not None else 1000 + return Page(items=list(range(limit)), next_cursor=start - 1) + + out = list(api.paginate(fetch, limit=3)) + assert len(out) == 3 + + +def test_paginate_stops_on_nondecreasing_cursor(): + def fetch(cursor, limit): + return Page(items=[1], next_cursor=5) # cursor never decreases + + out = list(api.paginate(fetch, limit=100)) + # page 1 (cursor=None) and page 2 (cursor=5) each yield one item; the + # non-decreasing cursor (5 >= 5) then halts the loop instead of spinning. + assert out == [1, 1] + + +def test_paginate_non_positive_limit_yields_nothing(): + def fetch(cursor, limit): # pragma: no cover - must not be called + raise AssertionError("fetch_page should not be called for limit <= 0") + + assert list(api.paginate(fetch, limit=0)) == [] + + +def test_paginate_honors_start_cursor(): + seen = [] + + def fetch(cursor, limit): + seen.append(cursor) + return Page(items=[1], next_cursor=None) + + list(api.paginate(fetch, start_cursor=42)) + assert seen == [42] diff --git a/fp-cli/tests/test_commands.py b/fp-cli/tests/test_commands.py new file mode 100644 index 000000000..d3d5784df --- /dev/null +++ b/fp-cli/tests/test_commands.py @@ -0,0 +1,754 @@ +from __future__ import annotations + +import json + +import httpx +import respx + +from fp_cli import config +from fp_cli.app import app + +BASE = "http://dash.test" + + +# --- auth commands ---------------------------------------------------------- + + +@respx.mock +def test_login_flow_persists_token(home, runner): + respx.post(f"{BASE}/api/auth/otp/request").mock(return_value=httpx.Response(200, json={"ok": True})) + respx.post(f"{BASE}/api/auth/otp/verify").mock( + return_value=httpx.Response( + 200, + json={"user": {"id": "u1", "email": "me@test"}, "expires_in_secs": 3600}, + headers={"set-cookie": "ae_session=tok-xyz; Path=/"}, + ) + ) + result = runner.invoke( + app, ["--base-url", BASE, "login", "--email", "me@test"], input="123456\n" + ) + assert result.exit_code == 0, result.output + assert config.load_config().session_token == "tok-xyz" + + +@respx.mock +def test_login_insecure_persists_flag(home, runner): + respx.post(f"{BASE}/api/auth/otp/request").mock(return_value=httpx.Response(200, json={"ok": True})) + respx.post(f"{BASE}/api/auth/otp/verify").mock( + return_value=httpx.Response( + 200, + json={"user": {"id": "u1", "email": "me@test"}, "expires_in_secs": 3600}, + headers={"set-cookie": "ae_session=tok-xyz; Path=/"}, + ) + ) + result = runner.invoke( + app, ["--base-url", BASE, "--insecure", "login", "--email", "me@test"], input="123456\n" + ) + assert result.exit_code == 0, result.output + saved = config.load_config() + assert saved.session_token == "tok-xyz" + assert saved.insecure is True # remembered, so later commands skip TLS verification too + + +def _seed_valid_session() -> None: + config.save_config( + config.CliConfig( + base_url=BASE, + session_token="existing", + expires_at="2099-01-01T00:00:00Z", + email="me@test", + user_id="u1", + org="acme", + ) + ) + + +@respx.mock +def test_login_already_signed_in_short_circuits(home, runner): + _seed_valid_session() + req = respx.post(f"{BASE}/api/auth/otp/request").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + result = runner.invoke(app, ["--base-url", BASE, "login"]) + assert result.exit_code == 0, result.output + assert "already signed in" in (result.stderr or "") + assert not req.called # short-circuited — no code requested + assert config.load_config().session_token == "existing" # session untouched + + +@respx.mock +def test_login_json_already_signed_in(home, runner): + _seed_valid_session() + result = runner.invoke(app, ["--base-url", BASE, "--json", "login"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + assert payload == { + "logged_in": True, + "email": "me@test", + "org": "acme", + "already_signed_in": True, + } + + +@respx.mock +def test_login_force_reauthenticates_when_signed_in(home, runner): + _seed_valid_session() + req = respx.post(f"{BASE}/api/auth/otp/request").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + respx.post(f"{BASE}/api/auth/otp/verify").mock( + return_value=httpx.Response( + 200, + json={"user": {"id": "u1", "email": "me@test"}, "expires_in_secs": 3600}, + headers={"set-cookie": "ae_session=newtok; Path=/"}, + ) + ) + result = runner.invoke( + app, ["--base-url", BASE, "login", "--force", "--email", "me@test"], input="123456\n" + ) + assert result.exit_code == 0, result.output + assert req.called # --force bypassed the short-circuit and re-authenticated + assert config.load_config().session_token == "newtok" + + +@respx.mock +def test_login_expired_session_proceeds(home, runner): + config.save_config( + config.CliConfig( + base_url=BASE, + session_token="oldtok", + expires_at="2020-01-01T00:00:00Z", # expired + email="me@test", + ) + ) + req = respx.post(f"{BASE}/api/auth/otp/request").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + respx.post(f"{BASE}/api/auth/otp/verify").mock( + return_value=httpx.Response( + 200, + json={"user": {"id": "u1", "email": "me@test"}, "expires_in_secs": 3600}, + headers={"set-cookie": "ae_session=freshtok; Path=/"}, + ) + ) + result = runner.invoke(app, ["--base-url", BASE, "login", "--email", "me@test"], input="123456\n") + assert result.exit_code == 0, result.output + assert req.called # expired → login proceeds normally + assert config.load_config().session_token == "freshtok" + + +@respx.mock +def test_secure_flag_overrides_saved_insecure(home, runner): + # Saved config has insecure=True; an explicit --secure must turn verification back on. + config.save_config(config.CliConfig(base_url=BASE, insecure=True)) + respx.post(f"{BASE}/api/auth/otp/request").mock(return_value=httpx.Response(200, json={"ok": True})) + respx.post(f"{BASE}/api/auth/otp/verify").mock( + return_value=httpx.Response( + 200, + json={"user": {"id": "u1", "email": "me@test"}, "expires_in_secs": 3600}, + headers={"set-cookie": "ae_session=tok; Path=/"}, + ) + ) + result = runner.invoke(app, ["--secure", "login", "--email", "me@test"], input="123456\n") + assert result.exit_code == 0, result.output + assert config.load_config().insecure is False # explicit --secure beat the stored True + + +@respx.mock +def test_logout_clears_token(logged_in, runner): + respx.post(f"{BASE}/api/auth/logout").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["logout"]) + assert result.exit_code == 0 + assert config.load_config().session_token is None + + +@respx.mock +def test_whoami_json(logged_in, runner): + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json={"id": "u1", "email": "me@test", "permissions": ["events:read"]}) + ) + result = runner.invoke(app, ["--json", "whoami"]) + assert result.exit_code == 0 + assert json.loads(result.stdout)["email"] == "me@test" + + +def test_whoami_not_logged_in(home, runner): + result = runner.invoke(app, ["--json", "whoami"]) + assert result.exit_code == 0 + assert json.loads(result.stdout)["logged_in"] is False + + +# --- exit-code contract ----------------------------------------------------- + + +def test_not_logged_in_exits_4(home, runner): + # URL is set (via flag); the missing token is what triggers exit 4. + result = runner.invoke(app, ["--base-url", BASE, "events"]) + assert result.exit_code == 4 + + +def test_no_base_url_defaults_to_hosted(home, runner): + # No --base-url, no env, no saved config -> the CLI defaults to the hosted + # dashboard (config.DEFAULT_BASE_URL) rather than erroring. With no token it + # then fails on auth (exit 4, "not signed in"), NOT a usage error (exit 2) + # about a missing URL. This pins that the default kicks in. + result = runner.invoke(app, ["sessions"]) + assert result.exit_code == 4 + + +def test_default_base_url_is_the_hosted_product(): + # The default the CLI falls back to is the hosted dashboard. + from fp_cli import config + from fp_cli._context import AppState, resolved_base_url + + assert config.DEFAULT_BASE_URL == "https://app.befailproof.ai" + # An AppState with no URL resolves to that default. + state = AppState(json=False, base_url=None, token=None, timeout=30.0, config=config.CliConfig()) + assert resolved_base_url(state) == config.DEFAULT_BASE_URL + + +@respx.mock +def test_forbidden_exits_5(logged_in, runner): + respx.get(f"{BASE}/api/users").mock( + return_value=httpx.Response(403, json={"error": "forbidden"}) + ) + result = runner.invoke(app, ["users", "list"]) + assert result.exit_code == 5 + + +@respx.mock +def test_network_error_exits_3(logged_in, runner): + respx.get(f"{BASE}/api/sessions").mock(side_effect=httpx.ConnectError("down")) + result = runner.invoke(app, ["sessions"]) + assert result.exit_code == 3 + + +def test_bad_since_is_usage_error(logged_in, runner): + result = runner.invoke(app, ["events", "--since", "bogus"]) + assert result.exit_code == 2 # click usage error + + +# --- query commands --------------------------------------------------------- + + +@respx.mock +def test_events_json(logged_in, runner): + # --session-id stays on the light feed (payload is a deliberate --full opt-in). + respx.get(f"{BASE}/api/events/summary").mock( + return_value=httpx.Response( + 200, + json={ + "events": [ + { + "id": 1, + "session_id": "s", + "agent_id": "a", + "event_type": "tool_use", + "ts": "t", + "summary": "slack.post", + "environment": "prod", + } + ], + "next_cursor": None, + }, + ) + ) + result = runner.invoke(app, ["--json", "events", "--session-id", "s"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["events"][0]["event_type"] == "tool_use" + + +@respx.mock +def test_events_all_paginates(logged_in, runner): + respx.get(f"{BASE}/api/events/summary").mock( + side_effect=[ + httpx.Response( + 200, + json={ + "events": [ + {"id": 3, "session_id": "s", "agent_id": "a", "event_type": "e", "ts": "t", "payload": {}, "environment": "p"}, + {"id": 2, "session_id": "s", "agent_id": "a", "event_type": "e", "ts": "t", "payload": {}, "environment": "p"}, + ], + "next_cursor": 2, + }, + ), + httpx.Response( + 200, + json={ + "events": [ + {"id": 1, "session_id": "s", "agent_id": "a", "event_type": "e", "ts": "t", "payload": {}, "environment": "p"} + ], + "next_cursor": None, + }, + ), + ] + ) + result = runner.invoke(app, ["--json", "events", "--all", "--limit", "100"]) + assert result.exit_code == 0, result.output + assert len(json.loads(result.stdout)["events"]) == 3 + + +@respx.mock +def test_events_feed_routing(logged_in, runner): + """Default → light feed (payload-free); --full / --fields payload / --session-id → full feed.""" + resp = httpx.Response(200, json={"events": [], "next_cursor": None}) + light = respx.get(f"{BASE}/api/events/summary").mock(return_value=resp) + full = respx.get(f"{BASE}/api/events").mock(return_value=resp) + + def counts(): + return light.call_count, full.call_count + + l, f = counts() + # bare / broad → light, never full + assert runner.invoke(app, ["--json", "events", "--env", "prod"]).exit_code == 0 + assert counts() == (l + 1, f); l, f = counts() + + # explicit --full → full + assert runner.invoke(app, ["--json", "events", "--full"]).exit_code == 0 + assert counts() == (l, f + 1); l, f = counts() + + # --fields payload (payload requested) → full + assert runner.invoke(app, ["--json", "events", "--fields", "id,payload"]).exit_code == 0 + assert counts() == (l, f + 1); l, f = counts() + + # --session-id stays on the LIGHT feed (fast session timeline; payload is a --full opt-in) + assert runner.invoke(app, ["--json", "events", "--session-id", "run-1"]).exit_code == 0 + assert counts() == (l + 1, f); l, f = counts() + + # --full + --session-id → full (bounded raw-payload read) + assert runner.invoke(app, ["--json", "events", "--full", "--session-id", "run-1"]).exit_code == 0 + assert counts() == (l, f + 1); l, f = counts() + + # --fields summary (light-only field) stays on the light feed + assert runner.invoke(app, ["--json", "events", "--fields", "id,summary"]).exit_code == 0 + assert counts() == (l + 1, f) + + +@respx.mock +def test_events_empty_with_filter_shows_recheck_hint(logged_in, runner): + # A filtered run that matches nothing exits 0 (not an error) and nudges the user to + # re-check the value, pointing at the facet that lists valid values. + respx.get(f"{BASE}/api/events/summary").mock( + return_value=httpx.Response(200, json={"events": [], "next_cursor": None}) + ) + result = runner.invoke(app, ["events", "--env", "xyz"]) + assert result.exit_code == 0, result.output + assert "no events match these filters" in result.output # the box (stdout) + assert "double-check" in result.stderr + assert "--env" in result.stderr + assert "fp list envs" in result.stderr # facet discovery hint + + +@respx.mock +def test_events_empty_no_filter_no_hint(logged_in, runner): + # A bare run with 0 rows is a genuinely empty window, not a typo — no recheck nudge. + respx.get(f"{BASE}/api/events/summary").mock( + return_value=httpx.Response(200, json={"events": [], "next_cursor": None}) + ) + result = runner.invoke(app, ["events"]) + assert result.exit_code == 0, result.output + assert "no events in this window" in result.output + assert "double-check" not in result.stderr + + +@respx.mock +def test_events_empty_json_has_no_hint(logged_in, runner): + # --json stays clean: the recheck nudge is stderr chrome, never on the JSON stdout. + respx.get(f"{BASE}/api/events/summary").mock( + return_value=httpx.Response(200, json={"events": [], "next_cursor": None}) + ) + result = runner.invoke(app, ["--json", "events", "--env", "xyz"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == {"events": [], "next_cursor": None} + assert "double-check" not in result.stderr + + +@respx.mock +def test_sessions_empty_with_filter_shows_recheck_hint(logged_in, runner): + # Same 0-result nudge on sessions: filtered run matching nothing exits 0 + names filters. + respx.get(f"{BASE}/api/sessions").mock( + return_value=httpx.Response(200, json={"sessions": [], "next_cursor": None}) + ) + result = runner.invoke(app, ["sessions", "--env", "xyz"]) + assert result.exit_code == 0, result.output + assert "no sessions match these filters" in result.output # the box (stdout) + assert "double-check" in result.stderr + assert "--env" in result.stderr + assert "fp list envs" in result.stderr # facet discovery hint + + +@respx.mock +def test_sessions_empty_no_filter_no_hint(logged_in, runner): + # A bare run with 0 rows is a genuinely empty result, not a typo — no recheck nudge. + respx.get(f"{BASE}/api/sessions").mock( + return_value=httpx.Response(200, json={"sessions": [], "next_cursor": None}) + ) + result = runner.invoke(app, ["sessions"]) + assert result.exit_code == 0, result.output + assert "no sessions" in result.output + assert "double-check" not in result.stderr + + +@respx.mock +def test_evals_list_empty_with_filter_shows_recheck_hint(logged_in, runner): + # Same 0-result nudge on evals list mode. + respx.get(f"{BASE}/api/evaluations").mock( + return_value=httpx.Response(200, json={"evaluations": [], "next_cursor": None}) + ) + result = runner.invoke(app, ["evals", "--env", "xyz"]) + assert result.exit_code == 0, result.output + assert "no evals match these filters" in result.output + assert "double-check" in result.stderr + assert "fp list envs" in result.stderr + + +@respx.mock +def test_evals_aggregate_empty_with_filter_shows_recheck_hint(logged_in, runner): + # The nudge also fires in aggregate mode when the slice is empty (total 0). + respx.get(f"{BASE}/api/evaluations/aggregate").mock( + return_value=httpx.Response( + 200, + json={"total": 0, "status_counts": {"done": 0, "error": 0, "timeout": 0}, + "score_stats": [], "timeline": []}, + ) + ) + result = runner.invoke(app, ["evals", "--aggregate", "--agent-id", "nope"]) + assert result.exit_code == 0, result.output + assert "double-check" in result.stderr + assert "--agent-id" in result.stderr + assert "fp list agents" in result.stderr + + +@respx.mock +def test_evals_aggregate_nonempty_no_hint(logged_in, runner): + # A non-empty aggregate (total > 0) never shows the recheck nudge. + respx.get(f"{BASE}/api/evaluations/aggregate").mock( + return_value=httpx.Response( + 200, + json={"total": 5, "status_counts": {"done": 5, "error": 0, "timeout": 0}, + "score_stats": [], "timeline": []}, + ) + ) + result = runner.invoke(app, ["evals", "--aggregate", "--env", "prod"]) + assert result.exit_code == 0, result.output + assert "double-check" not in result.stderr + + +@respx.mock +def test_errors_list_empty_with_filter_shows_recheck_hint(logged_in, runner): + # Same 0-result nudge on errors list mode; --error-type maps to its own facet. + respx.get(f"{BASE}/api/events/summary").mock( + return_value=httpx.Response(200, json={"events": [], "next_cursor": None}) + ) + result = runner.invoke(app, ["errors", "--error-type", "NopeError"]) + assert result.exit_code == 0, result.output + assert "no errors match these filters" in result.output + assert "double-check" in result.stderr + assert "fp list error_types" in result.stderr + + +@respx.mock +def test_errors_aggregate_empty_with_filter_shows_recheck_hint(logged_in, runner): + # Aggregate 0-total with an active filter shows the nudge under the "no errors found" card. + respx.get(f"{BASE}/api/events/error_summary").mock( + return_value=httpx.Response( + 200, json={"total": 0, "sessions": 0, "agents": 0, "last_ts": None, "bins": []} + ) + ) + result = runner.invoke(app, ["errors", "--aggregate", "--env", "xyz"]) + assert result.exit_code == 0, result.output + assert "no errors found" in result.output # the card stays + assert "double-check" in result.stderr + assert "fp list envs" in result.stderr + + +@respx.mock +def test_errors_aggregate_no_filter_no_hint(logged_in, runner): + # 0 errors with NO filter is genuinely clean — celebrate, don't nag. + respx.get(f"{BASE}/api/events/error_summary").mock( + return_value=httpx.Response( + 200, json={"total": 0, "sessions": 0, "agents": 0, "last_ts": None, "bins": []} + ) + ) + result = runner.invoke(app, ["errors", "--aggregate"]) + assert result.exit_code == 0, result.output + assert "no errors found" in result.output + assert "double-check" not in result.stderr + + +@respx.mock +def test_evals_score_filters(logged_in, runner): + route = respx.get(f"{BASE}/api/evaluations").mock( + return_value=httpx.Response(200, json={"evaluations": [], "next_cursor": None}) + ) + result = runner.invoke( + app, + ["--json", "evals", "--score", "helpfulness:0.5..0.8", "--score", "x:..0.3"], + ) + assert result.exit_code == 0, result.output + params = route.calls.last.request.url.params + assert params["score_filters"] == "helpfulness:0.5..0.8,x:..0.3" + + +def test_sessions_has_no_score_option(logged_in, runner): + # `--score` moved to `evals`; sessions must reject it (scores live on evals now). + assert runner.invoke(app, ["sessions", "--score", "helpfulness:..0.5"]).exit_code == 2 + + +@respx.mock +def test_sessions_table(logged_in, runner): + respx.get(f"{BASE}/api/sessions").mock( + return_value=httpx.Response( + 200, + json={ + "sessions": [ + { + "session_id": "s", + "agent_id": "a", + "environment": "prod", + "event_count": 5, + "started_at": "2026-06-22T12:00:00Z", + "last_event_at": "2026-06-22T12:05:00Z", + "latest_evaluation": { + "evaluation_id": "e1", + "status": "done", + "scores": {"helpfulness": 0.9}, + }, + } + ], + "next_cursor": None, + }, + ) + ) + result = runner.invoke(app, ["sessions"]) + assert result.exit_code == 0, result.output + assert "sessions" in result.stdout # the boxed panel title + assert "done" in result.stdout # status flattened up from latest_evaluation + + +@respx.mock +def test_sessions_json_flattens_status_and_scores(logged_in, runner): + # Flattening: status/scores are lifted to the top level of each row for back-compat, + # while the full evaluation stays under `latest_evaluation`. + respx.get(f"{BASE}/api/sessions").mock( + return_value=httpx.Response( + 200, + json={ + "sessions": [ + { + "session_id": "s", + "agent_id": "a", + "environment": "prod", + "last_event_at": "2026-06-22T12:05:00Z", + "latest_evaluation": {"status": "error", "scores": {"x": 0.5}}, + } + ], + "next_cursor": None, + }, + ) + ) + result = runner.invoke(app, ["--json", "sessions"]) + assert result.exit_code == 0, result.output + row = json.loads(result.stdout)["sessions"][0] + assert row["status"] == "error" # flattened to top level + assert row["scores"] == {"x": 0.5} # flattened to top level + assert row["latest_evaluation"]["status"] == "error" # nested source preserved + + +# ── multi-agent roster (agents column) ───────────────────────────────────── + +_MULTI_AGENT_SESSION = { + "session_id": "s", "agent_id": "agent-codegen", "environment": "dev", + "last_event_at": "2026-07-16T12:05:00Z", + "agents": [ + {"agent_id": "agent-codegen", "event_count": 52}, + {"agent_id": "agent-linter", "event_count": 18}, + {"agent_id": "agent-testgen", "event_count": 9}, + ], + "latest_evaluation": None, +} + + +@respx.mock +def test_sessions_json_includes_agents_roster(logged_in, runner): + respx.get(f"{BASE}/api/sessions").mock( + return_value=httpx.Response(200, json={"sessions": [_MULTI_AGENT_SESSION], "next_cursor": None}) + ) + result = runner.invoke(app, ["--json", "sessions"]) + assert result.exit_code == 0, result.output + row = json.loads(result.stdout)["sessions"][0] + # the full nested roster (agent_id + event_count) is carried through verbatim + assert row["agents"] == _MULTI_AGENT_SESSION["agents"] + + +@respx.mock +def test_sessions_table_badges_multi_agent(logged_in, runner): + respx.get(f"{BASE}/api/sessions").mock( + return_value=httpx.Response(200, json={"sessions": [_MULTI_AGENT_SESSION], "next_cursor": None}) + ) + result = runner.invoke(app, ["sessions"]) + assert result.exit_code == 0, result.output + assert "+2" in result.stdout # 3 agents → +2 badge + assert "1 multi-agent" in result.stderr # footer count + assert "fp sessions --agents" in result.stderr # footer hint + + +@respx.mock +def test_sessions_agents_flag_expands_roster(logged_in, runner): + respx.get(f"{BASE}/api/sessions").mock( + return_value=httpx.Response(200, json={"sessions": [_MULTI_AGENT_SESSION], "next_cursor": None}) + ) + result = runner.invoke(app, ["sessions", "--agents"]) + assert result.exit_code == 0, result.output + # the roster is expanded → the other agents' names + event counts are now visible + assert "agent-linter" in result.stdout and "agent-testgen" in result.stdout + assert "18 ev" in result.stdout + + +@respx.mock +def test_sessions_single_agent_no_badge(logged_in, runner): + respx.get(f"{BASE}/api/sessions").mock( + return_value=httpx.Response(200, json={ + "sessions": [{ + "session_id": "s", "agent_id": "solo", "environment": "dev", + "last_event_at": "2026-07-16T12:05:00Z", + "agents": [{"agent_id": "solo", "event_count": 5}], + "latest_evaluation": None, + }], + "next_cursor": None, + }) + ) + result = runner.invoke(app, ["sessions"]) + assert result.exit_code == 0, result.output + assert "+" not in result.stdout # single agent → no badge + assert "multi-agent" not in result.stderr # footer omits the segment + + +@respx.mock +def test_sessions_unevaluated_row_blank_status(logged_in, runner): + # A session never evaluated → latest_evaluation null → blank status (no crash). + respx.get(f"{BASE}/api/sessions").mock( + return_value=httpx.Response( + 200, + json={ + "sessions": [ + { + "session_id": "s", + "agent_id": "a", + "environment": "prod", + "last_event_at": "2026-06-22T12:05:00Z", + "latest_evaluation": None, + } + ], + "next_cursor": None, + }, + ) + ) + result = runner.invoke(app, ["--json", "sessions"]) + assert result.exit_code == 0, result.output + row = json.loads(result.stdout)["sessions"][0] + assert row["status"] == "" + assert row["scores"] is None + assert row["latest_evaluation"] is None + + +def test_version_command(home, runner): + from fp_cli import __version__ + + result = runner.invoke(app, ["version"]) + assert result.exit_code == 0 + assert __version__ in result.stdout # shown in the branded box + + +def test_version_json_global_form(home, runner): + # JSON via the GLOBAL --json (before the command), per the global option format. + from fp_cli import __version__ + + result = runner.invoke(app, ["--json", "version"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == {"version": __version__} + + +def test_version_json_after_command_is_rejected(home, runner): + # `--json` AFTER the command is NOT accepted — globals come before (usage error). + result = runner.invoke(app, ["version", "--json"]) + assert result.exit_code == 2 + + +def test_help_command(home, runner): + result = runner.invoke(app, ["help"]) + assert result.exit_code == 0 + out = result.stdout + assert "Commands" in out # the grouped top-level panel + # the four purpose groups, in order + for g in ("ESSENTIALS", "OBSERVE", "MANAGE", "TOOLS"): + assert g in out + assert out.index("ESSENTIALS") < out.index("OBSERVE") < out.index("MANAGE") < out.index("TOOLS") + assert "sessions" in out and "EXAMPLES" in out + + +def test_insecure_threads_verify_into_client_context(): + from fp_cli._context import AppState, build_context, require_auth + + cfg = config.CliConfig(base_url=BASE, session_token="t", expires_at="2999-01-01T00:00:00Z") + insecure = AppState(json=False, base_url=BASE, token="t", timeout=30.0, config=cfg, insecure=True) + assert require_auth(insecure).verify is False + assert build_context(insecure).verify is False + + secure = AppState(json=False, base_url=BASE, token="t", timeout=30.0, config=cfg, insecure=False) + assert require_auth(secure).verify is True + + +# --- new filters, --fields projection, and -h ------------------------------- + + +@respx.mock +def test_evals_agent_id_and_score_filters(logged_in, runner): + route = respx.get(f"{BASE}/api/evaluations").mock( + return_value=httpx.Response(200, json={"evaluations": [], "next_cursor": None}) + ) + result = runner.invoke( + app, ["--json", "evals", "--agent-id", "bot-1", "--score", "helpfulness:..0.5"] + ) + assert result.exit_code == 0, result.output + params = route.calls.last.request.url.params + assert params["agent_id"] == "bot-1" + assert params["score_filters"] == "helpfulness:..0.5" + assert "latest_per_session" not in params # not deduped + + +@respx.mock +def test_fields_projection_json(logged_in, runner): + # --fields without `payload` stays on the light feed. + respx.get(f"{BASE}/api/events/summary").mock( + return_value=httpx.Response( + 200, + json={ + "events": [ + {"id": 1, "session_id": "s", "agent_id": "a", "event_type": "tool_use", "ts": "t", "summary": "slack.post", "environment": "prod"} + ], + "next_cursor": None, + }, + ) + ) + result = runner.invoke(app, ["--json", "events", "--fields", "id,event_type"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["events"][0] == {"id": 1, "event_type": "tool_use"} + + +def test_fields_unknown_is_usage_error(logged_in, runner): + result = runner.invoke(app, ["--json", "events", "--fields", "bogus"]) + assert result.exit_code == 2 # BadParameter lists the valid fields + + +def test_dash_h_alias_shows_help(home, runner): + result = runner.invoke(app, ["-h"]) + assert result.exit_code == 0 + assert "Commands" in result.stdout and "sessions" in result.stdout + assert "ESSENTIALS" in result.stdout # the grouped top-level help, via the rich_format_help override + + +def test_subcommand_dash_h(home, runner): + result = runner.invoke(app, ["events", "-h"]) + assert result.exit_code == 0 + assert "--fields" in result.stdout diff --git a/fp-cli/tests/test_config.py b/fp-cli/tests/test_config.py new file mode 100644 index 000000000..b877fd249 --- /dev/null +++ b/fp-cli/tests/test_config.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import os +import stat +from datetime import datetime, timedelta, timezone + +from fp_cli import config + + +def _iso(delta: timedelta) -> str: + return (datetime.now(timezone.utc) + delta).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def test_load_missing_returns_defaults(home): + cfg = config.load_config() + assert cfg.base_url is None # no default URL — it must be set explicitly + assert cfg.session_token is None + + +def test_save_and_load_roundtrip(home): + cfg = config.CliConfig( + base_url="http://x.test", + session_token="t", + expires_at="2999-01-01T00:00:00Z", + email="e@test", + user_id="u", + ) + config.save_config(cfg) + assert config.load_config() == cfg + + +def test_save_uses_0600_permissions(home): + config.save_config(config.CliConfig()) + mode = stat.S_IMODE(os.stat(config.config_path()).st_mode) + assert mode == 0o600 + + +def test_base_dir_respects_fp_home(home): + assert config.base_dir() == home + + +def test_clear_token_wipes_session_identity_and_org(home): + # logout must leave NO stale identity: token, expiry, email, user id, and the + # active org are all cleared. base_url / insecure / anonymous_id are kept so the + # next login doesn't need them re-specified and telemetry stays stable. + config.save_config( + config.CliConfig( + base_url="http://x.test", + session_token="t", + expires_at="2999-01-01T00:00:00Z", + email="e@test", + user_id="u1", + insecure=True, + org="acme", + anonymous_id="anon-123", + ) + ) + config.clear_token(config.load_config()) + reloaded = config.load_config() + # cleared — nothing about who/where remains + assert reloaded.session_token is None + assert reloaded.expires_at is None + assert reloaded.email is None + assert reloaded.user_id is None + assert reloaded.org is None + # kept — preferences + stable machine id + assert reloaded.base_url == "http://x.test" + assert reloaded.insecure is True + assert reloaded.anonymous_id == "anon-123" + + +def test_corrupt_file_falls_back_to_defaults(home): + path = config.config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{ not json") + assert config.load_config().base_url is None + + +def test_is_expired(): + assert config.is_expired(config.CliConfig()) is True # no token at all + assert config.is_expired(config.CliConfig(session_token="t")) is True # no expiry + assert config.is_expired( + config.CliConfig(session_token="t", expires_at=_iso(timedelta(hours=-1))) + ) is True + assert config.is_expired( + config.CliConfig(session_token="t", expires_at=_iso(timedelta(hours=1))) + ) is False + + +def test_is_expired_handles_z_suffix_and_skew(): + # 30s in the future is "expired" under the default 60s skew. + soon = _iso(timedelta(seconds=30)) + assert config.is_expired(config.CliConfig(session_token="t", expires_at=soon)) is True + + +def test_insecure_defaults_false_and_roundtrips(home): + assert config.load_config().insecure is False # secure by default + config.save_config(config.CliConfig(insecure=True)) + assert config.load_config().insecure is True diff --git a/fp-cli/tests/test_dashboards_agent.py b/fp-cli/tests/test_dashboards_agent.py new file mode 100644 index 000000000..2bd87bbee --- /dev/null +++ b/fp-cli/tests/test_dashboards_agent.py @@ -0,0 +1,340 @@ +"""The agent assistant (SSE).""" + +from __future__ import annotations + +import json + +import httpx +import respx + +from fp_cli.app import app + +BASE = "http://dash.test" + + +def _sse(events) -> httpx.Response: + body = "".join(f"data: {json.dumps(e)}\n\n" for e in events) + return httpx.Response(200, text=body, headers={"content-type": "text/event-stream"}) + + +# --- agent ------------------------------------------------------------------ + + +@respx.mock +def test_agent_health(logged_in, runner): + respx.get(f"{BASE}/api/agent/health").mock( + return_value=httpx.Response(200, json={"enabled": True, "llm_configured": True, "default_model": "claude-x"}) + ) + result = runner.invoke(app, ["--json", "agent", "health"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["enabled"] is True + + +@respx.mock +def test_agent_models_lists_allowlist(logged_in, runner): + respx.get(f"{BASE}/api/agent/health").mock( + return_value=httpx.Response(200, json={"enabled": True, "models": ["m-default", "m-fast"], "defaultModel": "m-default"}) + ) + result = runner.invoke(app, ["--json", "agent", "models"]) + assert result.exit_code == 0, result.output + data = json.loads(result.stdout) + assert data["models"] == ["m-default", "m-fast"] + assert data["default_model"] == "m-default" + + +@respx.mock +def test_agent_chats_list(logged_in, runner): + respx.get(f"{BASE}/api/agent/conversations").mock( + return_value=httpx.Response(200, json={"conversations": [{"id": "c1", "title": "hi", "message_count": 2, "updated_at": "t"}]}) + ) + result = runner.invoke(app, ["--json", "agent", "chats"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["chats"][0]["id"] == "c1" + + +@respx.mock +def test_agent_ask_new_chat_creates_persists_and_returns_id(logged_in, runner): + respx.post(f"{BASE}/api/agent/chat").mock( + return_value=_sse([ + {"type": "text-delta", "text": "Hello "}, + {"type": "tool-start", "tool": "run_query", "toolCallId": "1"}, + {"type": "text-delta", "text": "world"}, + {"type": "done"}, + ]) + ) + create = respx.post(f"{BASE}/api/agent/conversations").mock( + return_value=httpx.Response(200, json={"id": "c9", "title": "New conversation"}) + ) + put = respx.put(f"{BASE}/api/agent/conversations/c9/messages").mock(return_value=httpx.Response(200, json={})) + patch = respx.patch(f"{BASE}/api/agent/conversations/c9").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["--json", "agent", "ask", "hi there"]) # message is positional now + assert result.exit_code == 0, result.output + data = json.loads(result.stdout) + assert data["answer"] == "Hello world" + assert data["tools"] == ["run_query"] + assert data["chat_id"] == "c9" # the new chat id is surfaced + assert create.called # no --chat ⇒ a new chat was created + # persisted [user, assistant] in the shared {role, content:{text}} shape + assert json.loads(put.calls.last.request.content)["messages"] == [ + {"role": "user", "content": {"text": "hi there"}}, + {"role": "assistant", "content": {"text": "Hello world"}}, + ] + assert json.loads(create.calls.last.request.content)["title"] == "hi there" # titled at creation + + +@respx.mock +def test_agent_ask_interactive_aborts_without_creating_chat(logged_in, runner): + # No conversation routes are mocked — if a chat were created, respx would error. + respx.post(f"{BASE}/api/agent/chat").mock( + return_value=_sse([ + {"type": "text-delta", "text": "let me check"}, + {"type": "ask-user", "callId": "x", "kind": "approval", "question": "ok to run?", "allowText": True}, + ]) + ) + result = runner.invoke(app, ["agent", "ask", "do it"]) + assert result.exit_code == 1 # clean abort, no orphan chat + + +@respx.mock +def test_agent_ask_not_configured_503(logged_in, runner): + respx.post(f"{BASE}/api/agent/chat").mock(return_value=httpx.Response(503, json={"error": "assistant not configured"})) + result = runner.invoke(app, ["agent", "ask", "hi"]) + assert result.exit_code == 1 # ApiError surfaced; no chat created + + +@respx.mock +def test_agent_ask_chat_continues_existing_thread(logged_in, runner): + # --chat resolves via the chat list first, then loads the thread by full id. + respx.get(f"{BASE}/api/agent/conversations").mock( + return_value=httpx.Response(200, json={"conversations": [{"id": "c1", "title": "perf review", "message_count": 2}]}) + ) + respx.get(f"{BASE}/api/agent/conversations/c1").mock( + return_value=httpx.Response(200, json={"title": "perf review", "messages": [ + {"role": "user", "content": {"text": "earlier q"}}, + {"role": "assistant", "content": {"text": "earlier a"}}, + ]}) + ) + chat = respx.post(f"{BASE}/api/agent/chat").mock( + return_value=_sse([{"type": "text-delta", "text": "follow up"}, {"type": "done"}]) + ) + put = respx.put(f"{BASE}/api/agent/conversations/c1/messages").mock(return_value=httpx.Response(200, json={})) + patch = respx.patch(f"{BASE}/api/agent/conversations/c1").mock(return_value=httpx.Response(200, json={})) + create = respx.post(f"{BASE}/api/agent/conversations").mock(return_value=httpx.Response(200, json={"id": "cNEW"})) + result = runner.invoke(app, ["--json", "agent", "ask", "and now?", "--chat", "c1"]) + assert result.exit_code == 0, result.output + # prior thread + new user turn sent to the agent for context + sent = json.loads(chat.calls.last.request.content)["messages"] + assert len(sent) == 3 and sent[-1] == {"role": "user", "content": {"text": "and now?"}} + # persisted = history + user + assistant; existing title NOT overwritten; no new chat made + saved = json.loads(put.calls.last.request.content)["messages"] + assert len(saved) == 4 and saved[-1] == {"role": "assistant", "content": {"text": "follow up"}} + assert not patch.called + assert not create.called + assert json.loads(result.stdout)["chat_id"] == "c1" + + +@respx.mock +def test_agent_ask_rejects_unknown_model(logged_in, runner): + respx.get(f"{BASE}/api/agent/health").mock( + return_value=httpx.Response(200, json={"enabled": True, "models": ["m-default", "m-fast"], "defaultModel": "m-default"}) + ) + chat = respx.post(f"{BASE}/api/agent/chat").mock(return_value=_sse([{"type": "done"}])) + result = runner.invoke(app, ["agent", "ask", "hi", "--model", "bogus-model"]) + assert result.exit_code == 2 # BadParameter — validated against the allowlist before chatting + assert not chat.called + + +@respx.mock +def test_agent_ask_accepts_allowlisted_model_and_forwards_it(logged_in, runner): + respx.get(f"{BASE}/api/agent/health").mock( + return_value=httpx.Response(200, json={"enabled": True, "models": ["m-default", "m-fast"], "defaultModel": "m-default"}) + ) + respx.post(f"{BASE}/api/agent/chat").mock(return_value=_sse([{"type": "text-delta", "text": "ok"}, {"type": "done"}])) + respx.post(f"{BASE}/api/agent/conversations").mock(return_value=httpx.Response(200, json={"id": "c9"})) + respx.put(f"{BASE}/api/agent/conversations/c9/messages").mock(return_value=httpx.Response(200, json={})) + respx.patch(f"{BASE}/api/agent/conversations/c9").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["--json", "agent", "ask", "hi", "--model", "m-fast"]) + assert result.exit_code == 0, result.output + chat_calls = [c for c in respx.calls if c.request.url.path == "/api/agent/chat"] + assert json.loads(chat_calls[-1].request.content)["model"] == "m-fast" # chosen model forwarded + + +# --- agent: redesigned UI (render assertions; the JSON contracts above are unchanged) --- + + +@respx.mock +def test_agent_health_renders_configured_card(logged_in, runner): + respx.get(f"{BASE}/api/agent/health").mock( + return_value=httpx.Response(200, json={"enabled": True, "llm_configured": True, + "default_model": "claude-x", "models": ["claude-x", "claude-fast"]}) + ) + result = runner.invoke(app, ["agent", "health"]) + assert result.exit_code == 0, result.output + out = result.stdout + assert "assistant" in out and "configured" in out and "claude-x" in out + + +@respx.mock +def test_agent_health_renders_not_configured(logged_in, runner): + respx.get(f"{BASE}/api/agent/health").mock( + return_value=httpx.Response(200, json={"enabled": False, "llm_configured": False}) + ) + result = runner.invoke(app, ["agent", "health"]) + assert result.exit_code == 0, result.output + assert "not configured" in result.stdout + + +@respx.mock +def test_agent_models_renders_default_marker(logged_in, runner): + respx.get(f"{BASE}/api/agent/health").mock( + return_value=httpx.Response(200, json={"enabled": True, "models": ["m-default", "m-fast"], "defaultModel": "m-default"}) + ) + result = runner.invoke(app, ["agent", "models"]) + assert result.exit_code == 0, result.output + out = result.stdout + assert "models" in out and "m-default" in out and "m-fast" in out and "default" in out + + +@respx.mock +def test_agent_models_empty_is_calm_and_notes_unconfigured(logged_in, runner): + respx.get(f"{BASE}/api/agent/health").mock(return_value=httpx.Response(200, json={"enabled": False})) + result = runner.invoke(app, ["agent", "models"]) + assert result.exit_code == 0, result.output + assert "no models reported" in result.stdout + assert "isn't configured" in (result.stderr or "") + + +@respx.mock +def test_agent_chats_renders_table(logged_in, runner): + respx.get(f"{BASE}/api/agent/conversations").mock( + return_value=httpx.Response(200, json={"conversations": [ + {"id": "c1", "title": "perf review", "message_count": 4, "updated_at": "2026-06-27T10:00:00Z"}, + ]}) + ) + result = runner.invoke(app, ["agent", "chats"]) + assert result.exit_code == 0, result.output + assert "chats" in result.stdout and "perf review" in result.stdout + assert "chat-id" in result.stdout and "c1" in result.stdout # full chat-id column by default + assert "open one with" not in (result.stderr or "") # footer removed + + +@respx.mock +def test_agent_chats_empty_is_calm(logged_in, runner): + respx.get(f"{BASE}/api/agent/conversations").mock(return_value=httpx.Response(200, json={"conversations": []})) + result = runner.invoke(app, ["agent", "chats"]) + assert result.exit_code == 0, result.output + assert "no chats" in result.stdout + + +@respx.mock +def test_agent_show_renders_thread(logged_in, runner): + # show resolves the (short) id via the chat list, then loads the thread by full id. + respx.get(f"{BASE}/api/agent/conversations").mock( + return_value=httpx.Response(200, json={"conversations": [{"id": "c1", "title": "perf review", "message_count": 2}]}) + ) + respx.get(f"{BASE}/api/agent/conversations/c1").mock( + return_value=httpx.Response(200, json={"title": "perf review", "messages": [ + {"role": "user", "content": {"text": "why slow"}}, + {"role": "assistant", "content": "because cache"}, # str-or-{text}: both extracted + ]}) + ) + result = runner.invoke(app, ["agent", "show", "c1"]) + assert result.exit_code == 0, result.output + out = result.stdout + assert "perf review" in out and "you" in out and "assistant" in out + assert "why slow" in out and "because cache" in out + + +@respx.mock +def test_agent_show_not_found_exits_6(logged_in, runner): + respx.get(f"{BASE}/api/agent/conversations").mock(return_value=httpx.Response(200, json={"conversations": []})) + result = runner.invoke(app, ["agent", "show", "ghost"]) + assert result.exit_code == 6 + assert "chat not found" in (result.stderr or result.output) + + +@respx.mock +def test_agent_show_not_found_json(logged_in, runner): + respx.get(f"{BASE}/api/agent/conversations").mock(return_value=httpx.Response(200, json={"conversations": []})) + result = runner.invoke(app, ["--json", "agent", "show", "ghost"]) + assert result.exit_code == 6 + assert "chat not found" in json.loads(result.stdout)["error"] + + +@respx.mock +def test_agent_rename_renders_card_and_keeps_json(logged_in, runner): + respx.get(f"{BASE}/api/agent/conversations").mock( + return_value=httpx.Response(200, json={"conversations": [{"id": "c1", "title": "old name", "message_count": 0}]}) + ) + patch = respx.patch(f"{BASE}/api/agent/conversations/c1").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["agent", "rename", "c1", "--title", "new name"]) + assert result.exit_code == 0, result.output + err = result.stderr or "" + assert "chat renamed" in err and "new name" in err and "was old name" in err + assert json.loads(patch.calls.last.request.content)["title"] == "new name" + # JSON contract unchanged (now the full resolved id) + result2 = runner.invoke(app, ["--json", "agent", "rename", "c1", "--title", "x"]) + assert json.loads(result2.stdout) == {"id": "c1", "title": "x"} + + +@respx.mock +def test_agent_rename_not_found_exits_6(logged_in, runner): + respx.get(f"{BASE}/api/agent/conversations").mock(return_value=httpx.Response(200, json={"conversations": []})) + result = runner.invoke(app, ["agent", "rename", "ghost", "--title", "x"]) + assert result.exit_code == 6 + assert "chat not found" in (result.stderr or result.output) + + +@respx.mock +def test_agent_delete_yes_renders_and_keeps_json(logged_in, runner): + respx.get(f"{BASE}/api/agent/conversations").mock( + return_value=httpx.Response(200, json={"conversations": [{"id": "c1", "title": "perf review", "message_count": 1}]}) + ) + delete = respx.delete(f"{BASE}/api/agent/conversations/c1").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["agent", "delete", "c1", "--yes"]) + assert result.exit_code == 0, result.output + err = result.stderr or "" + assert "deleted chat" in err and "perf review" in err + assert delete.called + # JSON contract (now also carries the title) + result2 = runner.invoke(app, ["--json", "agent", "delete", "c1", "--yes"]) + body = json.loads(result2.stdout) + assert body["deleted"] is True and body["id"] == "c1" and body["title"] == "perf review" + + +@respx.mock +def test_agent_delete_not_found_exits_6(logged_in, runner): + respx.get(f"{BASE}/api/agent/conversations").mock(return_value=httpx.Response(200, json={"conversations": []})) + result = runner.invoke(app, ["agent", "delete", "ghost", "--yes"]) + assert result.exit_code == 6 + assert "chat not found" in (result.stderr or result.output) + + +@respx.mock +def test_agent_ask_answer_to_stdout_chrome_to_stderr(logged_in, runner): + respx.post(f"{BASE}/api/agent/chat").mock( + return_value=_sse([ + {"type": "tool-start", "tool": "run_query"}, + {"type": "text-delta", "text": "the answer"}, + {"type": "done"}, + ]) + ) + respx.post(f"{BASE}/api/agent/conversations").mock(return_value=httpx.Response(200, json={"id": "c9"})) + respx.put(f"{BASE}/api/agent/conversations/c9/messages").mock(return_value=httpx.Response(200, json={})) + respx.patch(f"{BASE}/api/agent/conversations/c9").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["agent", "ask", "q"]) + assert result.exit_code == 0, result.output + assert result.stdout.strip() == "the answer" # piped (non-tty) → ONLY the raw answer on stdout + err = result.stderr or "" + assert "used tool: run_query" in err + assert "new chat" in err and "fp agent ask --chat c9" in err + + +@respx.mock +def test_agent_ask_assistant_error_clean_exit_1(logged_in, runner): + respx.post(f"{BASE}/api/agent/chat").mock( + return_value=_sse([{"type": "error", "message": "model overloaded"}]) + ) + result = runner.invoke(app, ["agent", "ask", "q"]) + assert result.exit_code == 1 + assert "✗" in (result.stderr or "") and "model overloaded" in (result.stderr or "") diff --git a/fp-cli/tests/test_dates.py b/fp-cli/tests/test_dates.py new file mode 100644 index 000000000..7e16f1886 --- /dev/null +++ b/fp-cli/tests/test_dates.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from fp_cli import dates + +NOW = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc) + + +def test_all_and_none_have_no_bounds(): + assert dates.resolve_range("all") == (None, None) + assert dates.resolve_range(None) == (None, None) + + +@pytest.mark.parametrize( + "preset,expected_from", + [ + ("15m", "2026-05-25T11:45:00Z"), + ("1h", "2026-05-25T11:00:00Z"), + ("6h", "2026-05-25T06:00:00Z"), + ("24h", "2026-05-24T12:00:00Z"), + ("7d", "2026-05-18T12:00:00Z"), + ], +) +def test_presets(preset, expected_from): + ts_from, ts_to = dates.resolve_range(preset, now=NOW) + assert ts_from == expected_from + assert ts_to is None + + +def test_custom_from_to_overrides_since(): + ts_from, ts_to = dates.resolve_range( + "24h", "2020-01-01T00:00:00Z", "2020-01-02T00:00:00Z", now=NOW + ) + assert ts_from == "2020-01-01T00:00:00Z" + assert ts_to == "2020-01-02T00:00:00Z" + + +def test_invalid_since_raises(): + with pytest.raises(ValueError): + dates.resolve_range("bogus", now=NOW) + + +@pytest.mark.parametrize( + "ok_value", + ["2026-05-01T00:00:00Z", "2026-05-01T00:00:00+00:00", "2026-05-01T12:30:00-05:00"], +) +def test_from_accepts_rfc3339_with_timezone(ok_value): + ts_from, _ = dates.resolve_range(None, ok_value, None, now=NOW) + assert ts_from == ok_value + + +@pytest.mark.parametrize( + "bad_value", + [ + "2026-05-01", # date only + "2026-05-01T00:00:00", # no timezone offset → server 400 + "2026-05-01 00:00:00Z", # space separator, not 'T' + "not-a-date", + ], +) +def test_from_rejects_naive_or_malformed(bad_value): + # These deserialize to a server 400 (exit 1); validating client-side keeps them a + # clean usage error (exit 2). Applies to --from and --to alike. + with pytest.raises(ValueError): + dates.resolve_range(None, bad_value, None, now=NOW) + with pytest.raises(ValueError): + dates.resolve_range(None, None, bad_value, now=NOW) + + +def test_since_choices_cover_presets(): + for preset in dates.PRESETS: + assert preset in dates.SINCE_CHOICES + assert "all" in dates.SINCE_CHOICES diff --git a/fp-cli/tests/test_enforcement_logic.py b/fp-cli/tests/test_enforcement_logic.py new file mode 100644 index 000000000..a08a4283e --- /dev/null +++ b/fp-cli/tests/test_enforcement_logic.py @@ -0,0 +1,481 @@ +"""The deploy planner, the race check, and source resolution. + +These are tested hard because they are the two places this feature can destroy +something: `PUT /enforcement/deployments/{id}` is a FULL REPLACE with no +server-side lock, so a wrong resulting set is a permanent silent undeploy, and a +missed race is somebody else's change gone with a 200 on screen. +""" +from __future__ import annotations + +import io + +import pytest + +from fp_cli.enforcement import ( + DeployPlan, + RefError, + check_race, + latest_versions, + parse_ref, + plan_deploy, + read_source, + resolve_ref, +) +from fp_cli.errors import ApiError +from fp_cli.models import PolicyRef, PolicyVersion + + +def ref(pid, version=1, effect="enforce"): + return PolicyRef(id=pid, version=version, effect=effect) + + +def pv(pid, version=1, archived=False): + return PolicyVersion( + id=pid, version=version, description="", sha256="", source=None, + created_at="", created_by=None, disabled=False, archived=archived, + ) + + +# ── parsing ────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "token,expected", + [ + ("a", ("a", None, None)), + ("a@3", ("a", 3, None)), + ("a:observe", ("a", None, "observe")), + ("a@3:observe", ("a", 3, "observe")), + ("a@3:enforce", ("a", 3, "enforce")), + ("no-force-push.v2_x", ("no-force-push.v2_x", None, None)), + ], +) +def test_parse_ref_shapes(token, expected): + assert parse_ref(token) == expected + + +@pytest.mark.parametrize("token", ["", " ", "a@", "a@x", "a:", "a:enforced", "a b", "a@1:bad"]) +def test_parse_ref_rejects_junk(token): + with pytest.raises(RefError): + parse_ref(token) + + +def test_an_unknown_effect_names_the_valid_ones(): + """The message has to say what IS allowed — 'invalid effect' helps nobody.""" + with pytest.raises(RefError, match="enforce, observe"): + parse_ref("a:audit") + + +# ── version and effect resolution ──────────────────────────────────────────── + + +def test_add_of_an_already_deployed_policy_keeps_its_version(): + """`--add` must not silently upgrade. + + A machine pinned to v1 while v3 exists is pinned deliberately. Treating a + bare `--add` as "give me the newest" would roll the fleet forward on a + command whose author was only reordering. + """ + got = resolve_ref("a", latest={"a": 3}, current={"a": ref("a", 1)}) + assert (got.version, got.effect) == (1, "enforce") + + +def test_add_of_a_new_policy_takes_the_latest_version(): + got = resolve_ref("a", latest={"a": 3}, current={}) + assert got.version == 3 + + +def test_an_explicit_version_always_wins(): + got = resolve_ref("a@2", latest={"a": 3}, current={"a": ref("a", 1)}) + assert got.version == 2 + + +def test_effect_is_inherited_then_defaults_to_enforce(): + assert resolve_ref("a", latest={"a": 1}, current={"a": ref("a", 1, "observe")}).effect == "observe" + assert resolve_ref("a", latest={"a": 1}, current={}).effect == "enforce" + assert resolve_ref("a:observe", latest={"a": 1}, current={}).effect == "observe" + + +def test_an_unpublished_policy_is_refused_before_any_write(): + with pytest.raises(RefError, match="no published policy"): + resolve_ref("ghost", latest={"a": 1}, current={}) + + +def test_latest_versions_ignores_archived(): + assert latest_versions([pv("a", 1), pv("a", 3, archived=True), pv("b", 2)]) == {"a": 1, "b": 2} + + +# ── the planner: the thing that decides what gets written ──────────────────── + + +def test_add_preserves_everything_already_deployed(): + """The whole reason --add exists. A full replace built from the delta alone + would drop `b` and `c` here, permanently, with a 200.""" + plan = plan_deploy( + "m", current=[ref("b"), ref("c")], base=4, add=["a"], latest={"a": 1}, + ) + assert [p.id for p in plan.result] == ["a", "b", "c"] + assert [p.id for p in plan.added] == ["a"] + assert [p.id for p in plan.unchanged] == ["b", "c"] + assert plan.removed == [] + + +def test_remove_takes_exactly_one_out(): + plan = plan_deploy("m", current=[ref("a"), ref("b")], base=1, remove=["a"]) + assert [p.id for p in plan.result] == ["b"] + assert [p.id for p in plan.removed] == ["a"] + + +def test_removing_something_not_deployed_is_refused(): + """Silently succeeding would let a typo read as "already gone".""" + with pytest.raises(RefError, match="not deployed"): + plan_deploy("m", current=[ref("a")], base=1, remove=["b"]) + + +def test_set_replaces_the_whole_set(): + plan = plan_deploy( + "m", current=[ref("a"), ref("b")], base=2, replace=["c"], latest={"c": 5}, + ) + assert [p.id for p in plan.result] == ["c"] + assert [p.id for p in plan.removed] == ["a", "b"] + assert plan.result[0].version == 5 + + +def test_set_cannot_be_mixed_with_add_or_remove(): + """"exactly these" and "these as well" have no single reading.""" + with pytest.raises(RefError, match="cannot be combined"): + plan_deploy("m", current=[], base=None, replace=["a"], add=["b"], latest={"a": 1, "b": 1}) + + +def test_a_version_or_effect_change_is_reported_as_changed_not_add_remove(): + plan = plan_deploy( + "m", current=[ref("a", 1, "enforce")], base=3, add=["a@2:observe"], latest={"a": 2}, + ) + assert plan.added == [] and plan.removed == [] + was, now = plan.changed[0] + assert (was.version, was.effect) == (1, "enforce") + assert (now.version, now.effect) == (2, "observe") + + +def test_a_noop_is_detectable_so_the_cli_can_skip_the_write(): + plan = plan_deploy("m", current=[ref("a")], base=1, add=["a"], latest={"a": 1}) + assert plan.is_noop is True + + +def test_deploying_to_a_machine_with_nothing_yet(): + plan = plan_deploy("m", current=None, base=None, add=["a"], latest={"a": 2}) + assert [p.label for p in plan.result] == ["a@2:enforce"] + assert plan.base is None + + +def test_the_result_is_sorted_so_two_equal_sets_serialise_identically(): + plan = plan_deploy("m", current=[], base=None, add=["c", "a", "b"], + latest={"a": 1, "b": 1, "c": 1}) + assert [p.id for p in plan.result] == ["a", "b", "c"] + + +def test_plan_json_carries_the_diff_a_harness_would_otherwise_recompute(): + plan = plan_deploy("m", current=[ref("b")], base=1, add=["a"], latest={"a": 1}) + d = plan.to_dict() + assert d["machineId"] == "m" and d["base"] == 1 and d["noop"] is False + assert [p["id"] for p in d["result"]] == ["a", "b"] + assert [p["id"] for p in d["added"]] == ["a"] + + +# ── the race check ─────────────────────────────────────────────────────────── + + +def test_a_clean_write_is_base_plus_one(): + check_race(4, 5) # no raise + + +def test_a_skipped_generation_means_someone_else_wrote(): + with pytest.raises(ApiError, match="someone else deployed"): + check_race(4, 7) + + +def test_a_repeated_generation_is_also_a_race(): + with pytest.raises(ApiError): + check_race(4, 4) + + +def test_a_first_deployment_has_no_base_to_check(): + check_race(None, 1) # no raise + + +def test_the_race_message_says_a_deploy_replaces(): + """The operator's next move depends on knowing it did not merge.""" + with pytest.raises(ApiError, match="REPLACES"): + check_race(1, 9) + + +# ── source input ───────────────────────────────────────────────────────────── + + +def test_source_from_a_path(tmp_path): + f = tmp_path / "p.mjs" + f.write_text("export default {}") + assert read_source(str(f)) == "export default {}" + + +def test_source_from_an_at_path(tmp_path): + f = tmp_path / "p.mjs" + f.write_text("x") + assert read_source(f"@{f}") == "x" + + +def test_source_from_explicit_stdin(): + assert read_source("-", stdin=io.StringIO("piped"), isatty=False) == "piped" + + +def test_source_from_a_pipe_with_no_argument(): + assert read_source(None, stdin=io.StringIO("piped"), isatty=False) == "piped" + + +def test_source_from_a_paste_prompts_first(): + """On a TTY, blocking on stdin without saying so is indistinguishable from a hang.""" + called = [] + out = read_source(None, stdin=io.StringIO("pasted"), isatty=True, prompt=lambda: called.append(1)) + assert out == "pasted" and called == [1] + + +def test_a_missing_file_names_the_path(): + with pytest.raises(RefError, match="no such file"): + read_source("/nope/definitely-not-here.mjs") + + +# ── the JSON contract ──────────────────────────────────────────────────────── + + +def test_models_emit_the_server_shape_not_pythons(): + """`vars()` would leak snake_case into a contract that is camelCase + everywhere else — a difference a harness finds at runtime, not in review.""" + from fp_cli.models import Deployment, Machine, PolicyVersion + + pv_keys = set(PolicyVersion.from_dict({"id": "a", "version": 1}).to_dict()) + assert "createdAt" in pv_keys and "created_at" not in pv_keys + + dep_keys = set(Deployment.from_dict({"machineId": "m", "deployment": 1}).to_dict()) + assert "machineId" in dep_keys and "machine_id" not in dep_keys + + m = Machine.from_dict({"machineId": "m", "deployment": 3, "appliedDeployment": 1}) + keys = set(m.to_dict()) + assert "appliedDeployment" in keys + assert not [k for k in keys if "_" in k], keys + + +def test_drift_is_intent_ahead_of_delivery(): + """The one field the CLI computes, and the reason `fleet diff` exists: a + machine can be deployed-to and still enforcing an older set.""" + from fp_cli.models import Machine + + def m(intended, delivered): + return Machine.from_dict({"machineId": "m", "deployment": intended, + "appliedDeployment": delivered}) + + assert m(3, 1).drifted is True # behind + assert m(3, None).drifted is True # never collected anything + assert m(3, 3).drifted is False # in sync + assert m(None, None).drifted is False # nothing deployed: not drift + + +def test_a_nul_byte_in_source_is_refused_with_a_readable_reason(): + """Reaching Postgres with a NUL returns a bare "database error" — an internal + failure shown to somebody who most likely pointed the command at a binary + file. Catching it here turns that into a sentence.""" + with pytest.raises(RefError, match="NUL byte"): + read_source("-", stdin=io.StringIO("export default {}\x00\x01"), isatty=False) + + +def test_the_nul_check_covers_every_input_shape(tmp_path): + """A guard on one of five paths is not a guard.""" + f = tmp_path / "bin.mjs" + f.write_text("ok\x00bad") + with pytest.raises(RefError, match="NUL byte"): + read_source(str(f)) + with pytest.raises(RefError, match="NUL byte"): + read_source(f"@{f}") + with pytest.raises(RefError, match="NUL byte"): + read_source(None, stdin=io.StringIO("a\x00b"), isatty=False) + with pytest.raises(RefError, match="NUL byte"): + read_source(None, stdin=io.StringIO("a\x00b"), isatty=True, prompt=lambda: None) + + +def test_ordinary_unicode_is_not_mistaken_for_binary(): + """Emoji and CJK are legitimate policy content; only NUL is refused.""" + assert read_source("-", stdin=io.StringIO("// 日本語 🎌\n"), isatty=False) == "// 日本語 🎌\n" + + +# ── disabled policies ──────────────────────────────────────────────────────── + + +def test_adding_a_disabled_policy_is_refused_before_the_plan_is_built(): + """The server rejects it anyway — but only after the CLI has drawn a plan + and asked the operator to confirm it, so the last thing on screen is a + change that cannot happen under a prompt that implied it could.""" + with pytest.raises(RefError, match="disabled"): + plan_deploy("m", current=[], base=None, add=["a"], + latest={"a": 1}, disabled={"a"}) + + +def test_the_refusal_names_the_command_that_fixes_it(): + with pytest.raises(RefError, match="policies enable a"): + plan_deploy("m", current=[], base=None, add=["a"], + latest={"a": 1}, disabled={"a"}) + + +def test_set_checks_disabled_too(): + """`--set` resolves refs by the same path; a gap in one is a gap in both.""" + with pytest.raises(RefError, match="disabled"): + plan_deploy("m", current=[], base=None, replace=["a"], + latest={"a": 1}, disabled={"a"}) + + +def test_a_disabled_policy_already_deployed_can_still_be_removed(): + """Defensive, for a state the server normally prevents. + + Disabling REMOVES a policy from every deployment carrying it (verified + against a live server: generation 16 held it, disabling minted 17 without + it), so a disabled policy should not appear in `current` at all. If one ever + does — a stale read, a server that changes this — refusing the removal would + leave it stuck on the machine with no CLI path off.""" + plan = plan_deploy("m", current=[ref("a")], base=1, remove=["a"], disabled={"a"}) + assert [p.id for p in plan.removed] == ["a"] + + +def test_an_unrelated_add_does_not_re_resolve_what_is_already_there(): + """Only the refs you name are resolved. Re-resolving the whole set would + make an unrelated `--add` fail because of something already on the machine + — the same trap in reverse.""" + plan = plan_deploy("m", current=[ref("a")], base=1, add=["b"], + latest={"a": 1, "b": 1}, disabled={"a"}) + assert [p.id for p in plan.result] == ["a", "b"] + assert [p.id for p in plan.unchanged] == ["a"] + + +def test_disabled_ids_ignores_archived(): + """An archived policy is already excluded from `latest`, so listing it here + too would produce 'disabled' for something that no longer exists.""" + from fp_cli.enforcement import disabled_ids + pols = [pv("live"), pv("off"), pv("gone", archived=True)] + pols[1].disabled = True + pols[2].disabled = True + assert disabled_ids(pols) == {"off"} + + +# ── machine labels ─────────────────────────────────────────────────────────── + + +def test_an_operator_rename_wins_over_the_machines_own_label(): + """The bug this fixes: `fleet rename` reported success and `fleet list` kept + showing `-`. The server stores the operator's name in `labelOverride`, a + DIFFERENT column from the machine's self-asserted `label`, and reading only + the latter made the rename invisible. Mirrors `machinePicker.ts`.""" + from fp_cli.models import Machine + m = Machine.from_dict({"machineId": "m", "label": "self-named", + "labelOverride": "operator-named"}) + assert m.display_label == "operator-named" + + +def test_the_machines_own_label_is_used_when_there_is_no_override(): + from fp_cli.models import Machine + assert Machine.from_dict({"machineId": "m", "label": "self-named"}).display_label == "self-named" + + +def test_no_label_at_all_is_none_not_an_empty_string(): + """The renderer substitutes a dash; an empty string would print as blank.""" + from fp_cli.models import Machine + assert Machine.from_dict({"machineId": "m"}).display_label is None + assert Machine.from_dict({"machineId": "m", "label": " ", + "labelOverride": ""}).display_label is None + + +def test_both_label_fields_survive_into_json(): + """A harness may want to know which of the two it is looking at.""" + from fp_cli.models import Machine + d = Machine.from_dict({"machineId": "m", "label": "a", "labelOverride": "b"}).to_dict() + assert d["label"] == "a" and d["labelOverride"] == "b" + + +# ── review round: what these commands got wrong ────────────────────────────── +# +# Every test below stands for a bug that shipped in the first cut of these +# commands and passed every test that existed at the time. They are grouped +# because they share a shape: the command did something defensible and then +# described it wrongly, or classified its own failure wrongly. + + +def test_a_binary_file_is_refused_by_name_not_by_traceback(tmp_path): + """`policies publish x logo.png` printed a Python traceback. + + `read_source` caught `OSError`, but decoding happens inside `read()` and + raises `UnicodeDecodeError`, which is a `ValueError` — so it sailed past the + handler and out through Click as a rich traceback with internal paths in it. + The NUL-byte guard could not save this: it inspects text, and a file that + fails to decode never becomes text. + """ + from fp_cli.enforcement import RefUsageError + + f = tmp_path / "logo.png" + f.write_bytes(b"\x89PNG\r\n\x1a\n\xff\xfe\x00\x01binary") + for value in (str(f), f"@{f}"): + with pytest.raises(RefUsageError, match="not UTF-8 text"): + read_source(value) + + +def test_a_binary_pipe_is_refused_the_same_way(): + """`cat logo.png | fp policies publish x` reaches a different branch of + `read_source` than a path does, and used to traceback from that one too.""" + from fp_cli.enforcement import RefUsageError + + class _Undecodable: + def isatty(self): + return False + + def read(self): + raise UnicodeDecodeError("utf-8", b"\xff\xfe", 0, 1, "invalid start byte") + + with pytest.raises(RefUsageError, match="not UTF-8 text"): + read_source("-", stdin=_Undecodable()) + with pytest.raises(RefUsageError, match="not UTF-8 text"): + read_source(None, stdin=_Undecodable(), isatty=False) + + +def test_mistyping_a_flag_is_a_usage_error_not_an_api_error(): + """These all exited 1 ("the server returned an error") for mistakes the + server never saw. The CLI documents 2 for usage, and `--since` / `--expect` + in these same commands already use it; a script branching on exit codes + could not tell a typo from a rejected write.""" + from fp_cli.enforcement import RefUsageError + + for token in ("", "bad ref!!", "policy:banana"): + with pytest.raises(RefUsageError): + parse_ref(token) + + with pytest.raises(RefUsageError, match="cannot be combined"): + plan_deploy("m", current=[], base=1, add=("a",), replace=("b",)) + + with pytest.raises(RefUsageError, match="no such file"): + read_source("/nope/definitely-not-here.mjs") + + +def test_usage_errors_are_still_ref_errors(): + """The split must not break `except RefError`, which is what every call site + and every earlier test in this file catches.""" + from fp_cli.enforcement import RefUsageError + + assert issubclass(RefUsageError, RefError) + with pytest.raises(RefError): + parse_ref("bad ref!!") + + +def test_a_server_refusal_stays_an_api_error(): + """The other half of the contract: naming a policy that does not exist is + not a typo the caller can fix by re-reading their own command line, and it + keeps exit 1. Widening the usage class to cover it would have made every + "this does not exist" indistinguishable from a malformed flag.""" + from fp_cli.enforcement import RefUsageError + + with pytest.raises(RefError) as caught: + resolve_ref("ghost", latest={}, current={}) + assert not isinstance(caught.value, RefUsageError) diff --git a/fp-cli/tests/test_facets.py b/fp-cli/tests/test_facets.py new file mode 100644 index 000000000..31ae8c8df --- /dev/null +++ b/fp-cli/tests/test_facets.py @@ -0,0 +1,170 @@ +"""Read/query completion: errors, eval-aggregate, events filters.""" + +from __future__ import annotations + +import json + +import httpx +import respx + +from fp_cli.app import app + +BASE = "http://dash.test" + + +@respx.mock +def test_errors_aggregate_json(logged_in, runner): + respx.get(f"{BASE}/api/events/error_summary").mock( + return_value=httpx.Response( + 200, + json={"total": 5, "sessions": 2, "agents": 1, "last_ts": "2026-01-01T00:00:00Z", "bins": [1, 2]}, + ) + ) + result = runner.invoke(app, ["--json", "errors", "--aggregate", "--since", "24h"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["total"] == 5 + + +@respx.mock +def test_errors_aggregate_card(logged_in, runner): + respx.get(f"{BASE}/api/events/error_summary").mock( + return_value=httpx.Response( + 200, json={"total": 66, "sessions": 62, "agents": 6, "last_ts": "2026-01-01T00:00:00Z", "bins": []} + ) + ) + result = runner.invoke(app, ["errors", "--aggregate", "--since", "24h"]) + assert result.exit_code == 0, result.output + assert "errors-aggregate" in result.stdout and "66" in result.stdout + assert "errored events" in result.stdout and "62 sessions" in result.stdout + + +@respx.mock +def test_errors_list_fetches_errored_events(logged_in, runner): + # errors now reads the LIGHT, payload-free feed (/api/events/summary) — server-computed + # `summary`/`is_error`, no payload. + route = respx.get(f"{BASE}/api/events/summary").mock( + return_value=httpx.Response( + 200, + json={ + "events": [ + {"id": 1, "session_id": "sess-1", "agent_id": "a", "event_type": "error", "ts": "t", + "environment": "prod", "summary": "TimeoutError: upstream timed out", + "is_error": True, "error_type": "TimeoutError", "output_tokens": None} + ], + "next_cursor": None, + }, + ) + ) + # bare `errors` now LISTS errored events (errored=true), not the summary + result = runner.invoke(app, ["--json", "errors", "--search", "boom", "--search", "oops", "--error-type", "TimeoutError"]) + assert result.exit_code == 0, result.output + data = json.loads(result.stdout) + assert data["errors"][0]["event_type"] == "error" # keyed under "errors", light rows + assert data["errors"][0]["summary"] == "TimeoutError: upstream timed out" # server summary, no payload + assert "payload" not in data["errors"][0] or not data["errors"][0]["payload"] # never the fat column + params = route.calls.last.request.url.params + assert params["errored"] == "true" + assert params["error_type"] == "TimeoutError" + assert params.get_list("search") == ["boom", "oops"] # repeated free-text params + + +@respx.mock +def test_errors_list_table(logged_in, runner): + respx.get(f"{BASE}/api/events/summary").mock( + return_value=httpx.Response( + 200, + json={ + "events": [ + {"id": 1, "session_id": "sess-20260622-4b90b240", "agent_id": "agent-orderbot", + "event_type": "error", "ts": "2026-06-22T17:51:34Z", + "summary": "RateLimitError: upstream timed out", "is_error": True, + "error_type": "RateLimitError", "output_tokens": None, "environment": "prod"} + ], + "next_cursor": None, + }, + ) + ) + result = runner.invoke(app, ["errors", "--since", "24h"]) + assert result.exit_code == 0, result.output + # the 80-col CliRunner truncates cells; just assert the error-themed box rendered + assert "errors" in result.stdout and "newest first" in result.stdout + + +@respx.mock +def test_evals_aggregate_json(logged_in, runner): + route = respx.get(f"{BASE}/api/evaluations/aggregate").mock( + return_value=httpx.Response( + 200, + json={ + "total": 10, + "status_counts": {"done": 8, "error": 1, "timeout": 1}, + "score_stats": [{"key": "helpfulness", "count": 8, "avg": 0.7, "min": 0.1, "max": 1.0, "p50": 0.75}], + "timeline": {"bucket_unit": "hour", "from": None, "to": "t", "points": []}, + }, + ) + ) + result = runner.invoke(app, ["--json", "evals", "--aggregate", "--since", "7d", "--env", "prod"]) + assert result.exit_code == 0, result.output + data = json.loads(result.stdout) + assert data["status_counts"]["done"] == 8 + assert data["score_stats"][0]["key"] == "helpfulness" + assert route.calls.last.request.url.params["environment"] == "prod" # filters thread through + + +@respx.mock +def test_evals_aggregate_table(logged_in, runner): + respx.get(f"{BASE}/api/evaluations/aggregate").mock( + return_value=httpx.Response( + 200, + json={ + "total": 10, + "status_counts": {"done": 8, "error": 2, "timeout": 0}, + "score_stats": [{"key": "helpfulness", "count": 8, "avg": 0.66, "min": 0.1, "max": 1.0, "p50": 0.6}], + "timeline": {"bucket_unit": "hour", "from": None, "to": "t", "points": []}, + }, + ) + ) + result = runner.invoke(app, ["evals", "--aggregate", "--since", "7d"]) + assert result.exit_code == 0, result.output + # totals card + score-stats panel + assert "eval-aggregate" in result.stdout and "evals" in result.stdout + assert "success rate" in result.stdout + assert "score stats" in result.stdout and "helpfulness" in result.stdout + + +@respx.mock +def test_events_filters_threaded(logged_in, runner): + # A broad events read (no --session-id/--full) uses the light feed. + route = respx.get(f"{BASE}/api/events/summary").mock( + return_value=httpx.Response(200, json={"events": [], "next_cursor": None}) + ) + result = runner.invoke( + app, + ["--json", "events", "--env", "prod,staging", "--event-type", "tool_use,tool_result", "--order", "asc"], + ) + assert result.exit_code == 0, result.output + params = route.calls.last.request.url.params + assert params["environment"] == "prod,staging" + assert params["event_type"] == "tool_use,tool_result" + assert params["order"] == "asc" + + +def test_events_bad_order_usage_error(logged_in, runner): + result = runner.invoke(app, ["events", "--order", "sideways"]) + assert result.exit_code == 2 + + +def test_events_nonpositive_limit_usage_error(logged_in, runner): + # --limit 0 / negative is rejected client-side (not passed through to the server). + assert runner.invoke(app, ["events", "--limit", "0"]).exit_code == 2 + assert runner.invoke(app, ["events", "-n", "-5"]).exit_code == 2 + + +def test_sessions_nonpositive_limit_usage_error(logged_in, runner): + assert runner.invoke(app, ["sessions", "--limit", "0"]).exit_code == 2 + assert runner.invoke(app, ["sessions", "-n", "-5"]).exit_code == 2 + + +def test_sessions_nonpositive_limit_usage_error(logged_in, runner): + assert runner.invoke(app, ["sessions", "--limit", "0"]).exit_code == 2 + assert runner.invoke(app, ["sessions", "-n", "-5"]).exit_code == 2 diff --git a/fp-cli/tests/test_failproofai_home.py b/fp-cli/tests/test_failproofai_home.py new file mode 100644 index 000000000..ffc49de08 --- /dev/null +++ b/fp-cli/tests/test_failproofai_home.py @@ -0,0 +1,722 @@ +"""The CLI's config lives inside a home another product owns. + +`~/.failproofai/` belongs to the Enforcement CLI. Its layout is declared in +`src/hooks/fp-home.ts`, mirrored for the daemon in +`crates/failproofaid/src/paths.rs`, and a reset walks it with +`rmSync(recursive)`. So the two things that matter here are not "can we write a +file" — they are: + + * we CREATE and never destroy. Anything already in that home survives every + path this module can take, including the failure paths. + * we are visible to the layout register, so a future migration knows a + credential lives under `fpcli/`. That half is asserted on the TS side + (`HOME_CLASSES`); what is asserted here is the Python half agreeing about + where the file goes. + +The legacy `~/.fp/cli.json` is read once and never deleted: `load_config` +adopts a pre-move session and copies it to the new path, leaving the original +so a downgrade still finds it. Both halves are tested below. + +This paragraph claimed the file was "neither read nor deleted" and that the +move forced a re-login. Adoption landed after the move, the tests 200 lines +down were updated for it, and this header was not — which is how a docstring +ends up contradicting the assertions in its own file. +""" +from __future__ import annotations + +import errno +import json +import os +import stat +from pathlib import Path + +import pytest + +from fp_cli import config as cfg + + +@pytest.fixture +def clean_env(monkeypatch, tmp_path): + """No inherited home vars, and `Path.home()` pinned inside the tmp dir.""" + for var in ("FP_HOME", "FAILPROOFAI_HOME"): + monkeypatch.delenv(var, raising=False) + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + return fake_home + + +def _child_writer(n: int, home: str) -> None: # pragma: no cover - child process + """Runs in a spawned interpreter, so it must be importable by name.""" + os.environ["FP_HOME"] = home + os.environ.pop("FAILPROOFAI_HOME", None) + from fp_cli import config as c + + for _ in range(15): + c.save_config(c.CliConfig(session_token=f"proc-{n}")) + + +# ── Where the file resolves ────────────────────────────────────────────────── + + +def test_default_is_under_the_failproofai_home(clean_env): + assert cfg.config_path() == clean_env / ".failproofai" / "fpcli" / "cli-auth.json" + + +def test_failproofai_home_env_appends_the_subdir(clean_env, monkeypatch, tmp_path): + """`FAILPROOFAI_HOME` names the HOME ROOT, so `fpcli/` is appended to it.""" + monkeypatch.setenv("FAILPROOFAI_HOME", str(tmp_path / "elsewhere")) + assert cfg.config_path() == tmp_path / "elsewhere" / "fpcli" / "cli-auth.json" + + +def test_fp_home_is_used_as_is(clean_env, monkeypatch, tmp_path): + """`FP_HOME` names the CLI's OWN directory — no subdir appended. + + That is what it meant before the move, so an existing export keeps + addressing the same directory it always did. + """ + monkeypatch.setenv("FP_HOME", str(tmp_path / "mine")) + assert cfg.config_path() == tmp_path / "mine" / "cli-auth.json" + + +def test_fp_home_wins_over_failproofai_home(clean_env, monkeypatch, tmp_path): + monkeypatch.setenv("FP_HOME", str(tmp_path / "mine")) + monkeypatch.setenv("FAILPROOFAI_HOME", str(tmp_path / "theirs")) + assert cfg.config_path() == tmp_path / "mine" / "cli-auth.json" + + +@pytest.mark.parametrize("var", ["FP_HOME", "FAILPROOFAI_HOME"]) +def test_an_empty_env_var_is_not_a_path(clean_env, monkeypatch, var): + """`FP_HOME=` must fall through, not resolve the config to `/cli-auth.json`.""" + monkeypatch.setenv(var, "") + assert cfg.config_path() == clean_env / ".failproofai" / "fpcli" / "cli-auth.json" + + +# ── Create, never destroy ──────────────────────────────────────────────────── + + +def test_creates_the_whole_chain_when_nothing_exists(clean_env): + assert not (clean_env / ".failproofai").exists() + cfg.save_config(cfg.CliConfig(session_token="t")) + assert cfg.config_path().is_file() + + +def test_a_populated_failproofai_home_is_left_intact(clean_env): + """The case this whole design is about: never wipe somebody else's home.""" + home = clean_env / ".failproofai" + (home / "policies").mkdir(parents=True) + (home / "policies" / "mine.mjs").write_text("// hand-written") + (home / "credentials.json").write_text('{"token": "enforcement-cli"}') + (home / "VERSION").write_text('{"layout": 4}') + (home / "state").mkdir() + (home / "state" / "spool").mkdir() + + cfg.save_config(cfg.CliConfig(session_token="t")) + + assert (home / "policies" / "mine.mjs").read_text() == "// hand-written" + assert json.loads((home / "credentials.json").read_text())["token"] == "enforcement-cli" + assert json.loads((home / "VERSION").read_text())["layout"] == 4 + assert (home / "state" / "spool").is_dir() + assert cfg.config_path().is_file() + + +def test_other_files_in_fpcli_survive_a_save(clean_env): + """We own `cli-auth.json`, not the directory it sits in.""" + fpcli = clean_env / ".failproofai" / "fpcli" + fpcli.mkdir(parents=True) + (fpcli / "unrelated.json").write_text("{}") + cfg.save_config(cfg.CliConfig(session_token="t")) + assert (fpcli / "unrelated.json").is_file() + + +def test_an_existing_auth_file_is_replaced_not_merged(clean_env): + """A stale session must not leak fields into the new one.""" + cfg.save_config(cfg.CliConfig(session_token="old", email="old@x", org="old-org")) + cfg.save_config(cfg.CliConfig(session_token="new")) + loaded = cfg.load_config() + assert loaded.session_token == "new" + assert loaded.email is None + assert loaded.org is None + + +def test_save_is_idempotent(clean_env): + for _ in range(3): + cfg.save_config(cfg.CliConfig(session_token="t")) + assert cfg.load_config().session_token == "t" + + +# ── Hostile filesystem ─────────────────────────────────────────────────────── + + +def test_home_occupied_by_a_regular_file_raises_not_corrupts(clean_env): + """`~/.failproofai` as a FILE must fail loudly and leave it byte-identical.""" + home = clean_env / ".failproofai" + home.write_text("not a directory") + with pytest.raises(OSError): + cfg.save_config(cfg.CliConfig(session_token="t")) + assert home.read_text() == "not a directory" + + +def test_fpcli_occupied_by_a_regular_file_raises(clean_env): + fpcli = clean_env / ".failproofai" / "fpcli" + fpcli.parent.mkdir(parents=True) + fpcli.write_text("not a directory") + with pytest.raises(OSError): + cfg.save_config(cfg.CliConfig(session_token="t")) + assert fpcli.read_text() == "not a directory" + + +@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores directory permissions") +def test_a_read_only_home_raises_and_changes_nothing(clean_env): + home = clean_env / ".failproofai" + home.mkdir() + (home / "credentials.json").write_text("keep me") + home.chmod(0o500) + try: + with pytest.raises(OSError): + cfg.save_config(cfg.CliConfig(session_token="t")) + assert (home / "credentials.json").read_text() == "keep me" + finally: + home.chmod(0o700) # so tmp_path cleanup can run + + +def test_a_symlinked_home_is_followed(clean_env, tmp_path): + """Some setups symlink the home onto another volume.""" + real = tmp_path / "real-home" + real.mkdir() + (clean_env / ".failproofai").symlink_to(real, target_is_directory=True) + cfg.save_config(cfg.CliConfig(session_token="t")) + assert (real / "fpcli" / "cli-auth.json").is_file() + + +def test_unreadable_config_reads_as_absent_rather_than_raising(clean_env): + """A corrupt file must not crash every command — it means "logged out".""" + cfg.save_config(cfg.CliConfig(session_token="t")) + cfg.config_path().write_text("{ not json") + assert cfg.load_config().session_token is None + + +# ── Permissions ────────────────────────────────────────────────────────────── + + +def test_auth_file_is_owner_only(clean_env): + cfg.save_config(cfg.CliConfig(session_token="t")) + assert stat.S_IMODE(os.stat(cfg.config_path()).st_mode) == 0o600 + + +def test_rewriting_a_loosened_file_restores_0600(clean_env): + cfg.save_config(cfg.CliConfig(session_token="t")) + cfg.config_path().chmod(0o644) + cfg.save_config(cfg.CliConfig(session_token="t2")) + assert stat.S_IMODE(os.stat(cfg.config_path()).st_mode) == 0o600 + + +# ── The legacy ~/.fp/cli.json ──────────────────────────────────────────────── + + +def _plant_legacy(home: Path) -> Path: + old = home / ".fp" + old.mkdir() + path = old / "cli.json" + path.write_text(json.dumps({"session_token": "legacy", "email": "old@x"})) + return path + + +def test_a_legacy_session_is_adopted(clean_env): + """Nobody is signed out by the move. The old session is picked up as-is.""" + _plant_legacy(clean_env) + loaded = cfg.load_config() + assert loaded.session_token == "legacy" + assert loaded.email == "old@x" + + +def test_adoption_writes_the_session_to_the_new_location(clean_env): + _plant_legacy(clean_env) + assert not cfg.config_path().exists() + cfg.load_config() + assert json.loads(cfg.config_path().read_text())["session_token"] == "legacy" + + +def test_adoption_is_a_copy_so_a_downgrade_still_works(clean_env): + """The old `fp` must still find its session if someone rolls back.""" + legacy = _plant_legacy(clean_env) + cfg.load_config() + assert json.loads(legacy.read_text())["session_token"] == "legacy" + + +def test_adoption_does_not_happen_when_a_current_session_exists(clean_env): + _plant_legacy(clean_env) + cfg.save_config(cfg.CliConfig(session_token="current")) + assert cfg.load_config().session_token == "current" + + +def test_a_corrupt_legacy_file_is_skipped_not_adopted(clean_env): + old = clean_env / ".fp" + old.mkdir() + (old / "cli.json").write_text("{ not json") + assert cfg.load_config().session_token is None + + +@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores directory permissions") +def test_an_unwritable_target_still_hands_back_the_session(clean_env): + """Our housekeeping must never be the reason someone is logged out.""" + _plant_legacy(clean_env) + home = clean_env / ".failproofai" + home.mkdir() + home.chmod(0o500) + try: + assert cfg.load_config().session_token == "legacy" + finally: + home.chmod(0o700) + + +def test_a_relocated_legacy_session_is_adopted(clean_env, monkeypatch, tmp_path): + """`FP_HOME` users are carried across too — their old file is beside the new.""" + relocated = tmp_path / "custom" + relocated.mkdir() + (relocated / "cli.json").write_text(json.dumps({"session_token": "relocated"})) + monkeypatch.setenv("FP_HOME", str(relocated)) + assert cfg.load_config().session_token == "relocated" + + +def test_fp_home_does_not_reach_into_the_home_directory(clean_env, monkeypatch, tmp_path): + """Someone who redirected the config said where it lives. Respect that. + + Reaching past `FP_HOME` into `~/.fp` would adopt a session from a different + context — another tenant, or another user's leftovers on a shared box. + """ + _plant_legacy(clean_env) # a session at ~/.fp/cli.json + empty = tmp_path / "empty" + empty.mkdir() + monkeypatch.setenv("FP_HOME", str(empty)) + assert cfg.load_config().session_token is None + + +def test_a_legacy_session_is_never_deleted(clean_env): + """Deleting a file the user did not ask us to touch is not ours to do.""" + legacy = _plant_legacy(clean_env) + cfg.save_config(cfg.CliConfig(session_token="new")) + assert legacy.is_file() + assert json.loads(legacy.read_text())["session_token"] == "legacy" + + +def test_a_relocated_legacy_session_is_detected_too(clean_env, monkeypatch, tmp_path): + """The group most likely to be broken silently: `FP_HOME` users. + + The move changed the FILENAME as well as the directory, so somebody who + exported `FP_HOME` has their old session at `$FP_HOME/cli.json` and may own + no `~/.fp` at all. Checking only the default hands exactly those users an + unexplained logout. + """ + relocated = tmp_path / "custom" + relocated.mkdir() + (relocated / "cli.json").write_text('{"session_token": "old"}') + monkeypatch.setenv("FP_HOME", str(relocated)) + + assert cfg.legacy_install_detected() is True + assert cfg.legacy_config_path() == relocated / "cli.json" + + +def test_the_relocated_path_is_named_before_the_default(clean_env, monkeypatch, tmp_path): + """When both exist, name the one THIS invocation would have read. + + Naming the default instead tells an `FP_HOME` user to delete an unrelated + file — on a machine they may share. + """ + _plant_legacy(clean_env) # the default, ~/.fp/cli.json + relocated = tmp_path / "custom" + relocated.mkdir() + (relocated / "cli.json").write_text('{"session_token": "old"}') + monkeypatch.setenv("FP_HOME", str(relocated)) + + assert cfg.legacy_config_path() == relocated / "cli.json" + + +def test_no_legacy_anywhere_reports_none(clean_env): + assert cfg.legacy_config_path() is None + assert cfg.legacy_install_detected() is False + + +def test_legacy_install_detected_only_before_the_first_login(clean_env): + _plant_legacy(clean_env) + assert cfg.legacy_install_detected() is True + cfg.save_config(cfg.CliConfig(session_token="new")) + assert cfg.legacy_install_detected() is False + + +def test_no_legacy_file_means_no_notice(clean_env): + assert cfg.legacy_install_detected() is False + + +# ── The neighbouring roots stay separate ───────────────────────────────────── + + +def test_the_sdk_spool_root_is_untouched(clean_env, monkeypatch): + """`~/.agenteye` is a wire contract with the collector, not a preference. + + A save must not create, move or read it — renaming it from this side would + write events into a directory nothing watches. + """ + monkeypatch.setenv("AGENTEYE_HOME", str(clean_env / ".agenteye")) + cfg.save_config(cfg.CliConfig(session_token="t")) + assert not (clean_env / ".agenteye").exists() + + +# ── Symlinks: we write into a directory full of another product's secrets ──── + + +def test_a_symlinked_config_never_writes_through_to_a_neighbour(clean_env): + """The bug the move created. + + `O_TRUNC` follows symlinks. A link at `cli-auth.json` pointing at + `../credentials.json` therefore made `fp login` truncate the Enforcement + CLI's token and write ours over it — no error, no trace. Harmless while the + CLI owned `~/.fp` outright; not harmless in a shared home. + """ + home = clean_env / ".failproofai" + (home / "fpcli").mkdir(parents=True) + victim = home / "credentials.json" + victim.write_text('{"token": "enforcement-cli"}') + cfg.config_path().symlink_to(victim) + + with pytest.raises(OSError, match="symbolic link"): + cfg.save_config(cfg.CliConfig(session_token="ours")) + + assert json.loads(victim.read_text())["token"] == "enforcement-cli" + + +def test_a_dangling_symlinked_config_is_refused_too(clean_env): + """Not just "does the target matter" — following at all is the bug.""" + (clean_env / ".failproofai" / "fpcli").mkdir(parents=True) + cfg.config_path().symlink_to(clean_env / "nowhere.json") + with pytest.raises(OSError, match="symbolic link"): + cfg.save_config(cfg.CliConfig(session_token="t")) + assert not (clean_env / "nowhere.json").exists() + + +def test_the_refusal_does_not_delete_the_link(clean_env): + """A symlink is something a person put there. Refuse; never clean up.""" + (clean_env / ".failproofai" / "fpcli").mkdir(parents=True) + target = clean_env / "target.json" + target.write_text("{}") + cfg.config_path().symlink_to(target) + with pytest.raises(OSError): + cfg.save_config(cfg.CliConfig(session_token="t")) + assert cfg.config_path().is_symlink() + + +def test_a_symlinked_fpcli_directory_is_followed(clean_env, tmp_path): + """Only the FINAL component is guarded. A relocated directory is fine.""" + real = tmp_path / "real-fpcli" + real.mkdir() + (clean_env / ".failproofai").mkdir() + (clean_env / ".failproofai" / "fpcli").symlink_to(real, target_is_directory=True) + cfg.save_config(cfg.CliConfig(session_token="t")) + assert (real / "cli-auth.json").is_file() + + +def test_a_config_path_occupied_by_a_directory_raises(clean_env): + (clean_env / ".failproofai" / "fpcli").mkdir(parents=True) + cfg.config_path().mkdir() + with pytest.raises(OSError): + cfg.save_config(cfg.CliConfig(session_token="t")) + assert cfg.config_path().is_dir() + + +def test_a_broken_symlinked_home_raises_rather_than_writing_elsewhere(clean_env, tmp_path): + (clean_env / ".failproofai").symlink_to(tmp_path / "does-not-exist", target_is_directory=True) + with pytest.raises(OSError): + cfg.save_config(cfg.CliConfig(session_token="t")) + + +# ── Permissions of what we create ──────────────────────────────────────────── + + +@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores directory permissions") +def test_created_directories_are_not_group_or_world_writable(clean_env): + """`fpcli/` holds a credential, so it is created `0700` regardless of umask. + + A group-writable directory does not expose the `0600` file inside it, but it + does let anyone in the group replace that file — which is a session swap. + + The shared parent is deliberately NOT asserted on: when we are the first to + create `~/.failproofai` we leave it to the umask, exactly as the Enforcement + CLI would, and when it already exists we must not re-permission a home we do + not own. + """ + cfg.save_config(cfg.CliConfig(session_token="t")) + mode = stat.S_IMODE(os.stat(clean_env / ".failproofai" / "fpcli").st_mode) + assert not mode & stat.S_IWGRP, f"fpcli/ is group-writable ({oct(mode)})" + assert not mode & stat.S_IWOTH, f"fpcli/ is world-writable ({oct(mode)})" + assert not mode & stat.S_IRGRP and not mode & stat.S_IROTH, oct(mode) + + +def test_an_existing_shared_home_is_never_re_permissioned(clean_env): + """We hardened `fpcli/`; doing the same to a home we do not own is not ours.""" + home = clean_env / ".failproofai" + home.mkdir(mode=0o755) + before = stat.S_IMODE(os.stat(home).st_mode) + cfg.save_config(cfg.CliConfig(session_token="t")) + assert stat.S_IMODE(os.stat(home).st_mode) == before + + +def test_an_existing_fpcli_dir_keeps_its_mode(clean_env): + """`exist_ok=True` must not chmod — a user who widened it chose that.""" + fpcli = clean_env / ".failproofai" / "fpcli" + fpcli.mkdir(parents=True, mode=0o755) + before = stat.S_IMODE(os.stat(fpcli).st_mode) + cfg.save_config(cfg.CliConfig(session_token="t")) + assert stat.S_IMODE(os.stat(fpcli).st_mode) == before + + +@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores directory permissions") +def test_an_untraversable_home_raises_and_keeps_its_contents(clean_env): + home = clean_env / ".failproofai" + (home / "fpcli").mkdir(parents=True) + (home / "credentials.json").write_text("keep me") + home.chmod(0o000) + try: + with pytest.raises(OSError): + cfg.save_config(cfg.CliConfig(session_token="t")) + finally: + home.chmod(0o700) + assert (home / "credentials.json").read_text() == "keep me" + + +@pytest.mark.skipif(os.geteuid() == 0, reason="root can read any file") +def test_an_unreadable_config_reads_as_logged_out(clean_env): + """EACCES on read must not crash every command.""" + cfg.save_config(cfg.CliConfig(session_token="t")) + cfg.config_path().chmod(0o000) + try: + assert cfg.load_config().session_token is None + finally: + cfg.config_path().chmod(0o600) + + +# ── Env var shapes ─────────────────────────────────────────────────────────── + + +def test_a_trailing_slash_resolves_to_the_same_file(clean_env, monkeypatch, tmp_path): + monkeypatch.setenv("FAILPROOFAI_HOME", f"{tmp_path / 'h'}/") + assert cfg.config_path() == tmp_path / "h" / "fpcli" / "cli-auth.json" + + +def test_a_relative_env_path_is_taken_relative_to_cwd(clean_env, monkeypatch, tmp_path): + """Documented behaviour rather than an accident: `Path` does not absolutise.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("FP_HOME", "rel-home") + cfg.save_config(cfg.CliConfig(session_token="t")) + assert (tmp_path / "rel-home" / "cli-auth.json").is_file() + + +def test_a_deeply_nested_home_creates_every_parent(clean_env, monkeypatch, tmp_path): + monkeypatch.setenv("FAILPROOFAI_HOME", str(tmp_path / "a" / "b" / "c" / "d")) + cfg.save_config(cfg.CliConfig(session_token="t")) + assert cfg.config_path().is_file() + + +# ── Logout, and concurrency ────────────────────────────────────────────────── + + +def test_logout_clears_the_session_without_touching_the_shared_home(clean_env): + home = clean_env / ".failproofai" + home.mkdir() + (home / "credentials.json").write_text('{"token": "enforcement-cli"}') + cfg.save_config(cfg.CliConfig(session_token="t", email="a@b", org="acme", + base_url="https://x", anonymous_id="anon")) + out = cfg.clear_token(cfg.load_config()) + + assert out.session_token is None and out.email is None and out.org is None + # kept on purpose, so the next login needs no re-configuring + assert out.base_url == "https://x" and out.anonymous_id == "anon" + assert json.loads((home / "credentials.json").read_text())["token"] == "enforcement-cli" + assert cfg.config_path().is_file() + + +def test_concurrent_saves_leave_valid_json(clean_env): + """Two `fp` processes can race. Last write wins; a torn file does not.""" + import threading + + errors: list = [] + + def writer(n: int) -> None: + try: + for _ in range(20): + cfg.save_config(cfg.CliConfig(session_token=f"tok-{n}")) + except Exception as exc: # pragma: no cover - surfaced via `errors` + errors.append(exc) + + threads = [threading.Thread(target=writer, args=(i,)) for i in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors + assert cfg.load_config().session_token.startswith("tok-") + + +# ── Hard links, atomicity, and the temp file ───────────────────────────────── + + +def test_a_hard_linked_config_does_not_clobber_its_twin(clean_env): + """`O_NOFOLLOW` says nothing about hard links — they are not links. + + A hard link is a second NAME for one inode, so an in-place write went + straight through it into the neighbour's file. `os.replace` swaps the + directory entry instead, leaving the other name on the old inode. + """ + home = clean_env / ".failproofai" + (home / "fpcli").mkdir(parents=True) + victim = home / "credentials.json" + victim.write_text('{"token": "enforcement-cli"}') + os.link(victim, cfg.config_path()) + + cfg.save_config(cfg.CliConfig(session_token="ours")) + + assert json.loads(victim.read_text())["token"] == "enforcement-cli" + assert cfg.load_config().session_token == "ours" + + +def test_no_temp_file_survives_a_successful_save(clean_env): + cfg.save_config(cfg.CliConfig(session_token="t")) + leftovers = [p.name for p in cfg.config_path().parent.iterdir() if p.name.endswith(".tmp")] + assert leftovers == [] + + +def test_no_temp_file_survives_a_failed_save(clean_env, monkeypatch): + """A disk-full or killed write must not strand a credential beside the real one.""" + cfg.save_config(cfg.CliConfig(session_token="original")) + real_replace = os.replace + + def boom(src, dst): + raise OSError(errno.ENOSPC, "No space left on device") + + monkeypatch.setattr(os, "replace", boom) + with pytest.raises(OSError): + cfg.save_config(cfg.CliConfig(session_token="doomed")) + monkeypatch.setattr(os, "replace", real_replace) + + leftovers = [p.name for p in cfg.config_path().parent.iterdir() if p.name.endswith(".tmp")] + assert leftovers == [] + # and the previous session is still readable — the write never landed + assert cfg.load_config().session_token == "original" + + +def test_a_reader_never_sees_a_half_written_file(clean_env): + """Atomicity, asserted through the only observable that matters. + + The rename is the whole point: a reader either gets the old session or the + new one, never a splice. Simulated by failing between write and rename, + which is exactly the window an in-place write left open. + """ + cfg.save_config(cfg.CliConfig(session_token="v1", email="a@b")) + before = cfg.config_path().read_text() + + import unittest.mock as mock + + with mock.patch.object(os, "replace", side_effect=OSError(errno.EIO, "io")): + with pytest.raises(OSError): + cfg.save_config(cfg.CliConfig(session_token="v2")) + + assert cfg.config_path().read_text() == before + assert cfg.load_config().session_token == "v1" + + +def test_concurrent_processes_leave_one_whole_session(clean_env): + """Threads share a pid; processes do not. Both must land on a valid file.""" + import multiprocessing as mp + + target = str(cfg.config_path().parent) + ctx = mp.get_context("spawn") + procs = [ctx.Process(target=_child_writer, args=(i, target)) for i in range(4)] + for p in procs: + p.start() + for p in procs: + p.join(timeout=60) + + assert all(p.exitcode == 0 for p in procs), [p.exitcode for p in procs] + assert cfg.load_config().session_token.startswith("proc-") + leftovers = [p.name for p in cfg.config_path().parent.iterdir() if p.name.endswith(".tmp")] + assert leftovers == [] + + +# ── Exotic paths ───────────────────────────────────────────────────────────── + + +def test_a_path_with_spaces_and_unicode(clean_env, monkeypatch, tmp_path): + monkeypatch.setenv("FAILPROOFAI_HOME", str(tmp_path / "my home ünïcode 目录")) + cfg.save_config(cfg.CliConfig(session_token="t")) + assert cfg.load_config().session_token == "t" + + +def test_a_fifo_in_the_config_position_is_refused(clean_env): + """A FIFO in the config position must not hang the CLI. + + Measured, not theorised: with the previous in-place `os.open(..., O_TRUNC)` + this test does not fail, it HANGS — opening a FIFO for writing blocks until + a reader appears, so `fp login` waits forever with no output. A mutation run + that removed the temp-and-rename sat here for ten minutes before being + killed. Writing to a fresh temp file and renaming over the FIFO cannot + block, because the open never touches the FIFO at all. + """ + (clean_env / ".failproofai" / "fpcli").mkdir(parents=True) + try: + os.mkfifo(cfg.config_path()) + except (AttributeError, OSError): # pragma: no cover - platform without FIFOs + pytest.skip("mkfifo unavailable") + # The rename replaces the directory entry; the FIFO must not be written into. + cfg.save_config(cfg.CliConfig(session_token="t")) + assert not stat.S_ISFIFO(os.lstat(cfg.config_path()).st_mode) + assert cfg.load_config().session_token == "t" + + +# ── The shipped text must not name the old location ────────────────────────── + + +def test_no_shipped_module_names_the_pre_move_path(): + """Help text is a docstring, so a stale path compiles, ships and passes. + + This is the same failure the `fp.events` example was: prose that describes + the product, wrong, with nothing to catch it. Four modules named + `~/.fp/cli.json` after the move — `login`, `logout` and `orgs switch` all + print theirs to the user. + + `config.py` is exempt: it is where the legacy path is deliberately named, to + recognise a pre-move install and say so. + """ + import fp_cli + + pkg = Path(fp_cli.__file__).parent + offenders = [] + for path in sorted(pkg.rglob("*.py")): + if path.name == "config.py": + continue + if "~/.fp/cli.json" in path.read_text(encoding="utf-8"): + offenders.append(str(path.relative_to(pkg))) + assert offenders == [], f"these still name the pre-move config path: {offenders}" + + +def test_logout_is_not_undone_by_adoption(clean_env): + """The sharp edge of adopting: it must not resurrect a session on purpose. + + `logout` writes a config with no token rather than deleting the file, so + adoption has to key off the file being ABSENT or unreadable — not off "there + is no token here". Keying off the token would make every command after a + logout re-adopt `~/.fp/cli.json` and sign the user back in, which is worse + than the problem adoption solves. + """ + _plant_legacy(clean_env) + assert cfg.load_config().session_token == "legacy" # adopted + cfg.clear_token(cfg.load_config()) # `fp logout` + assert cfg.load_config().session_token is None # and it stays out + + +def test_a_current_config_without_a_token_blocks_adoption(clean_env): + """The same invariant stated directly, without going through logout.""" + _plant_legacy(clean_env) + cfg.save_config(cfg.CliConfig(base_url="https://x")) # no token + loaded = cfg.load_config() + assert loaded.session_token is None + assert loaded.base_url == "https://x" diff --git a/fp-cli/tests/test_fp_home_contract.py b/fp-cli/tests/test_fp_home_contract.py new file mode 100644 index 000000000..2c743a24d --- /dev/null +++ b/fp-cli/tests/test_fp_home_contract.py @@ -0,0 +1,198 @@ +"""The CLI writes into a home another component owns, so both halves must agree. + +`~/.failproofai/` is a governed layout. `src/hooks/fp-home.ts` is its register — +"nothing outside this file may join a path onto the failproofai home" — and +`resettablePaths()` is a *filter over* `HOME_CLASSES`, so what protects the +CLI's credential from a reset is its entry in that table, not the fact that the +file happens to exist. + +Until this file existed, nothing checked that the two sides agreed. `config.py` +said so itself, in a comment above `FPCLI_SUBDIR`: *"Mirrors ``fpcliDir`` in +``src/hooks/fp-home.ts`` — change one, change the other; nothing checks."* +Verified by experiment: renaming `fpcliDir` to `fp-cli` in the TypeScript and +leaving Python alone left 53 TS tests and 59 Python tests all passing, with the +register describing a directory nothing writes and the real credential sitting +at a path the register had never heard of. + +That is the same shape as the SDK's `tests/test_spool_contract.py` next door, +which reads the Rust and the TypeScript that define its spool root. This is the +CLI's version of it. + +The assertions run against source text rather than a running Node, because +requiring a toolchain to test a Python package would mean this skips in every +environment that matters and guards nothing — the failure mode the SDK's own +contract test had for its whole life before it moved into this repo. +""" +from __future__ import annotations + +import os +import re +from pathlib import Path + +import pytest + +from fp_cli import config as cfg + +# tests/ -> fp-cli/ -> repo root. +# +# Guarded: `parents[2]` raises IndexError on a shallower tree, and a shallower +# tree is exactly the packaged-sdist case `_read_source` below handles. Raising +# here would be a COLLECTION error, which aborts the whole suite instead of +# skipping the one file that needs the repository. +_HERE = Path(__file__).resolve() +REPO_ROOT = _HERE.parents[2] if len(_HERE.parents) > 2 else _HERE.parent +FP_HOME_TS = REPO_ROOT / "src" / "hooks" / "fp-home.ts" + +#: Set by CI. Turns "the source I read is missing" from a skip into a failure. +REQUIRE = os.environ.get("FP_CLI_REQUIRE_CONTRACT", "").strip().lower() in { + "1", + "true", + "yes", + "on", +} + + +def _read_source() -> str: + """`fp-home.ts`, or skip/fail depending on ``REQUIRE``. + + Missing means one of two things: this is an installed sdist (fine — there is + no repo to read), or the file moved (not fine). ``REQUIRE`` distinguishes + them, because from in here they look identical. + """ + if FP_HOME_TS.is_file(): + return FP_HOME_TS.read_text(encoding="utf-8") + message = ( + f"{FP_HOME_TS} is missing. In a packaged sdist that is expected. In the " + "repository it means the layout register moved, and this contract is now " + "unguarded — re-point this test at the new location rather than deleting it." + ) + if REQUIRE: + pytest.fail(message) + pytest.skip(message) + + +def _declared(pattern: str, source: str, what: str) -> str: + """The single capture of `pattern`, failing loudly if it matched 0 or 2+. + + A regex over source that quietly matches nothing is worse than no test: it + passes forever while checking a file that has been rewritten around it. + """ + matches = re.findall(pattern, source) + assert len(matches) == 1, ( + f"expected exactly one declaration of {what} in fp-home.ts, found " + f"{len(matches)}. The register was restructured; re-anchor this test " + f"rather than loosening the pattern." + ) + return matches[0] + + +# ───────────────────────────────────────────────────────────────────────────── +# The paths themselves +# ───────────────────────────────────────────────────────────────────────────── + + +def test_the_subdirectory_name_agrees(): + """`fpcliDir` in TypeScript vs `FPCLI_SUBDIR` here.""" + source = _read_source() + declared = _declared( + r'export const fpcliDir = \(home\?: string\) => atHome\(home, "([^"]+)"\)', + source, + "fpcliDir", + ) + assert declared == cfg.FPCLI_SUBDIR, ( + f"fp-home.ts registers the CLI directory as {declared!r} and this package " + f"writes {cfg.FPCLI_SUBDIR!r}. The credential would sit at a path the " + f"layout register has never heard of, so nothing protects it from a reset " + f"the moment somebody classifies its parent." + ) + + +def test_the_credential_filename_agrees(): + source = _read_source() + declared = _declared( + r'export const fpcliAuthFile = \(home\?: string\) => resolve\(fpcliDir\(home\), "([^"]+)"\)', + source, + "fpcliAuthFile", + ) + assert declared == cfg.config_path().name + + +def test_the_home_directory_name_agrees(): + """Both sides hardcode `.failproofai`; neither imports it from the other.""" + source = _read_source() + declared = _declared( + r'return process\.env\.FAILPROOFAI_HOME \|\| resolve\(homedir\(\), "([^"]+)"\)', + source, + "the failproofai home", + ) + home = cfg.base_dir().parent.name + assert declared == home, f"fp-home.ts says {declared!r}, this package writes {home!r}" + + +def test_the_shared_home_override_variable_agrees(): + """`FAILPROOFAI_HOME` relocates the whole layout; the CLI must follow it.""" + source = _read_source() + assert "process.env.FAILPROOFAI_HOME" in source + assert "FAILPROOFAI_HOME" in Path(cfg.__file__).read_text(encoding="utf-8") + + +# ───────────────────────────────────────────────────────────────────────────── +# The classification — what actually keeps a reset off the credential +# ───────────────────────────────────────────────────────────────────────────── + + +def test_the_credential_is_registered_as_user_typed(): + """This entry, not the file's existence, is what survives `resettablePaths()`. + + `resettablePaths()` filters `HOME_CLASSES`, so an unregistered path survives + only by accident — and only until someone lists its parent. `user-typed` is + the class that says "a person typed this; nothing regenerates it", which is + exactly a login. + """ + source = _read_source() + assert re.search(r"\{\s*path:\s*fpcliAuthFile,\s*class:\s*\"user-typed\"\s*\}", source), ( + "fpcliAuthFile is no longer classified `user-typed` in HOME_CLASSES. A " + "reset walks that table; the CLI's session is not regenerable and must " + "never be on the delete list." + ) + + +def test_the_directory_itself_is_deliberately_unclassified(): + """`auditDir`'s rule: classify the children, never the parent. + + A `user-typed` parent would protect a cache added later; a `derived` parent + would delete the session beside it. + """ + source = _read_source() + assert not re.search(r"\{\s*path:\s*fpcliDir,\s*class:", source), ( + "fpcliDir is now classified as a whole. If the directory has grown a " + "second file, classify that file — one class cannot be right for a " + "credential and a cache at once." + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# The anchors, so none of the above can pass vacuously +# ───────────────────────────────────────────────────────────────────────────── + + +def test_the_register_still_contains_what_these_patterns_anchor_on(): + """Every regex above reads source text, and source text gets rewritten. + + If `fp-home.ts` is restructured so the patterns stop matching, the tests + above fail loudly via `_declared`. This one covers the rest of the file: + the names must still be exported and still be mentioned in the class table. + """ + source = _read_source() + for anchor in ( + "export const fpcliDir", + "export const fpcliAuthFile", + "export const HOME_CLASSES", + "fpcliAuthFile, class:", + ): + assert anchor in source, f"fp-home.ts no longer contains {anchor!r}" + + +def test_this_file_can_be_imported_outside_the_repository(): + """A collection error here would abort the whole CLI suite, not just this file.""" + assert REPO_ROOT.is_absolute() diff --git a/fp-cli/tests/test_hardening.py b/fp-cli/tests/test_hardening.py new file mode 100644 index 000000000..4d4060e7f --- /dev/null +++ b/fp-cli/tests/test_hardening.py @@ -0,0 +1,135 @@ +"""Regression tests for the cli-bug-fix hardening pass. + +Covers the crash/robustness fixes: pagination cursor type-mix, guarded file reads, +the non-JSON read-body trap, 429 messaging, explicit ISO date validation, non-finite +score bounds, and the empty-token (no silent saved-session fallback) guard. +""" + +from __future__ import annotations + +from fp_cli import _click_compat as click # the Click Typer is running +import httpx +import pytest +import respx + +from fp_cli import client, config, dates +from fp_cli._context import validate_score_filters +from fp_cli.app import app +from fp_cli.client import ClientContext +from fp_cli.commands._write import read_text_arg +from fp_cli.errors import ApiError +from fp_cli.models import Page + +BASE = "http://dash.test" + + +def _ctx() -> ClientContext: + return ClientContext(base_url=BASE, token="t", org="globex") + + +# --- paginate: int/str cursor mix must not crash (was a TypeError) ---------- + + +def test_paginate_mixed_cursor_types_no_crash(): + # start_cursor is a str (from --cursor); pages return int next_cursor. + pages = [Page(items=[1, 2], next_cursor=100), Page(items=[3], next_cursor=None)] + seq = iter(pages) + + def fetch(cursor, limit): + return next(seq) + + got = list(client.paginate(fetch, limit=10, page_size=2, start_cursor="9999999999999999")) + assert got == [1, 2, 3] + + +def test_paginate_stops_on_repeated_cursor(): + # A server that returns the same cursor forever must not loop forever. + def fetch(cursor, limit): + return Page(items=["x"], next_cursor=42) + + got = list(client.paginate(fetch, limit=1000, page_size=1, start_cursor=None)) + assert 0 < len(got) < 1000 # bounded by the seen-cursor guard + + +# --- read_text_arg: missing file -> usage error, not a traceback ----------- + + +def test_read_text_arg_missing_file_is_usage_error(): + with pytest.raises(click.BadParameter): + read_text_arg("/no/such/file.json") + + +# --- _get_json: non-JSON 2xx body -> ApiError, not raw JSONDecodeError ------ + + +@respx.mock +def test_get_json_non_json_body_raises_clean_apierror(): + respx.get(f"{BASE}/api/events").mock( + return_value=httpx.Response(200, text="<html>not json</html>") + ) + with pytest.raises(ApiError): + client._get_json(_ctx(), "/api/events") + + +@respx.mock +def test_429_includes_retry_after(): + respx.get(f"{BASE}/api/events").mock( + return_value=httpx.Response(429, headers={"retry-after": "5"}, json={}) + ) + with pytest.raises(ApiError) as ei: + client._get_json(_ctx(), "/api/events") + assert "Retry after 5" in str(ei.value) + + +# --- dates: explicit --from/--to validation -------------------------------- + + +def test_resolve_range_rejects_garbage_date(): + with pytest.raises(ValueError): + dates.resolve_range(ts_from="not-a-date") + + +def test_resolve_range_rejects_date_only(): + with pytest.raises(ValueError): + dates.resolve_range(ts_from="2026-06-01") + + +def test_resolve_range_accepts_full_iso(): + frm, _ = dates.resolve_range(ts_from="2026-05-01T00:00:00Z") + assert frm == "2026-05-01T00:00:00Z" + + +# --- validate_score_filters: reject non-finite bounds ---------------------- + + +@pytest.mark.parametrize("value", ["helpfulness:nan..", "x:0.0..inf", "y:-inf..1"]) +def test_score_filter_rejects_non_finite(value): + with pytest.raises(click.BadParameter): + validate_score_filters([value]) + + +def test_score_filter_accepts_valid_ranges(): + validate_score_filters(["helpfulness:0.5..0.8", "x:..0.3", "y:0.9.."]) # no raise + + +@pytest.mark.parametrize("value", ["helpfulness:..", "x:..", "metric:.."]) +def test_score_filter_rejects_both_bounds_empty(value): + # `KEY:..` (no min, no max) is meaningless — the server silently drops it and + # returns the UNFILTERED set, so it must be a clean client-side usage error. + with pytest.raises(click.BadParameter): + validate_score_filters([value]) + + +# --- empty --token must NOT silently use the saved session ----------------- + + +def test_empty_token_does_not_fall_back_to_saved_session(home, runner): + config.save_config(config.CliConfig(base_url=BASE, session_token="saved-tok")) + # `--token ""` (e.g. an unset CI var) is an explicit "no auth", not a fallback. + result = runner.invoke(app, ["--base-url", BASE, "--token", "", "events"]) + assert result.exit_code == 4, result.output # not logged in (was: used saved-tok) + + +def test_negative_timeout_is_usage_error(home, runner): + result = runner.invoke(app, ["--base-url", BASE, "--timeout", "-1", "events"]) + assert result.exit_code == 2, result.output diff --git a/fp-cli/tests/test_help_table_coverage.py b/fp-cli/tests/test_help_table_coverage.py new file mode 100644 index 000000000..7add9bb44 --- /dev/null +++ b/fp-cli/tests/test_help_table_coverage.py @@ -0,0 +1,91 @@ +"""The top-level help screen is a HAND-MAINTAINED table, not Click's command tree. + +`output.render_top_level_help` renders `_TOP_LEVEL_GROUPS`, a literal list. A command +registered on the Typer app but missing from that list works perfectly and is invisible +in `fp help` forever — there is no error, no warning, and no other test that looks. + +These tests close that gap in both directions, and additionally assert the help text +carries the current command name, which is the thing a rename silently leaves stale +(Click derives its own `Usage:` line from argv[0], so the auto-generated half updates +itself and the hand-written half does not). +""" + +from __future__ import annotations + +import re + +from typer.main import get_command + +from fp_cli import output +from fp_cli.app import app + + +def _registered_commands() -> set[str]: + """Every command name Click actually knows about.""" + cmd = get_command(app) + return set(cmd.commands) # type: ignore[attr-defined] + + +def _help_table_commands() -> set[str]: + """Every command name the hand-maintained help table advertises.""" + names = set() + for _group, entries in output._TOP_LEVEL_GROUPS: + for entry in entries: + names.add(entry[0]) + return names + + +def test_every_registered_command_appears_in_the_help_table(): + missing = _registered_commands() - _help_table_commands() + assert not missing, ( + f"these commands are registered but absent from output._TOP_LEVEL_GROUPS, so " + f"`fp help` will never mention them: {sorted(missing)}" + ) + + +def test_the_help_table_never_advertises_a_command_that_does_not_exist(): + extra = _help_table_commands() - _registered_commands() + assert not extra, ( + f"output._TOP_LEVEL_GROUPS advertises commands that are not registered — " + f"`fp <name>` would be a usage error: {sorted(extra)}" + ) + + +def test_the_help_table_is_not_empty(): + """Guards the assertions above from passing vacuously if the structure changes.""" + assert len(_help_table_commands()) >= 15 + + +def test_help_chrome_carries_no_retired_product_name(): + """A rename updates Click's generated usage for free and the prose not at all.""" + rendered = "\n".join( + str(entry) for _group, entries in output._TOP_LEVEL_GROUPS for entry in entries + ) + examples = "\n".join(f"{cmd} {why}" for cmd, why in output._TOP_LEVEL_EXAMPLES) + haystack = f"{rendered}\n{examples}".lower() + assert "agenteye" not in haystack, "retired product name still present in help chrome" + + +def test_the_help_examples_use_the_current_command_name(): + cmds = [cmd for cmd, _why in output._TOP_LEVEL_EXAMPLES] + assert cmds, "no examples to check" + assert all(c.startswith("fp ") for c in cmds), ( + f"help examples must invoke `fp`: {[c for c in cmds if not c.startswith('fp ')]}" + ) + + +def test_no_module_advertises_the_retired_env_var_namespace(): + """`FP_*` is this CLI's namespace. `AGENTEYE_*` names that remain must be explicit + references to OTHER components (the collector's ingest key, the dashboard's admin + key, the SDK/collector spool dir) and never something this CLI reads.""" + import pathlib + + pkg = pathlib.Path(output.__file__).resolve().parent + allowed = {"AGENTEYE_KEY", "AGENTEYE_API_KEY", "AGENTEYE_HOME"} + found = set() + for mod in pkg.rglob("*.py"): + for m in re.finditer(r"AGENTEYE" + r"_[A-Z_]+", mod.read_text()): + found.add(m.group(0)) + assert found <= allowed, ( + f"these retired env vars are still referenced in the package: {sorted(found - allowed)}" + ) diff --git a/fp-cli/tests/test_keys_queries.py b/fp-cli/tests/test_keys_queries.py new file mode 100644 index 000000000..0e15d6900 --- /dev/null +++ b/fp-cli/tests/test_keys_queries.py @@ -0,0 +1,496 @@ +"""Dev provisioning: API keys (one-time secret) and saved queries / SQL runner.""" + +from __future__ import annotations + +import json +import re + +import httpx +import pytest +import respx + +from fp_cli.app import app +from fp_cli.commands.keys_cmds import PermissionTokenError, _parse_permissions + + +def test_audit_permissions_are_assignable_and_in_presets(): + from fp_cli.permissions import ALL_PERMISSIONS, PRESETS + + assert "audits:read" in ALL_PERMISSIONS + assert "audits:write" in ALL_PERMISSIONS + assert "audits:read" in PRESETS["read-only"] + assert "usage:read" in ALL_PERMISSIONS + assert "usage:read" in PRESETS["read-only"] + assert "audits:write" in PRESETS["admin"] + + +def test_policy_permissions_are_assignable_and_in_presets(): + from fp_cli.permissions import ALL_PERMISSIONS, PRESETS + + assert "policies:read" in ALL_PERMISSIONS + assert "policies:write" in ALL_PERMISSIONS + assert "policies:pull" in ALL_PERMISSIONS + assert "policies:read" in PRESETS["read-only"] + assert "policies:read" in PRESETS["standard"] + assert "policies:pull" in PRESETS["admin"] + +BASE = "http://dash.test" + + +def test_parse_permissions_expands_and_dedupes(): + # dotted actions expand to flat slug:action, across tokens, de-duplicated, order preserved + assert _parse_permissions(["events:read.add", "keys:read.create"]) == [ + "events:read", "events:add", "keys:read", "keys:create"] + assert _parse_permissions(["events:read", "events:read.add"]) == ["events:read", "events:add"] + assert _parse_permissions(["events:read.add keys:read"]) == ["events:read", "events:add", "keys:read"] # whitespace in one token + + +@pytest.mark.parametrize("bad", [["events"], ["events:"], ["events:frobnicate"], []]) +def test_parse_permissions_rejects(bad): + with pytest.raises(PermissionTokenError): + _parse_permissions(bad) + + +def test_parse_permission_tokens_require_nonempty_flag(): + # The shared parser (in permissions.py) lets users --add/--remove be empty when asked. + from fp_cli.permissions import PermissionTokenError as PTErr + from fp_cli.permissions import parse_permission_tokens + assert parse_permission_tokens([], require_nonempty=False) == [] + assert parse_permission_tokens(None, require_nonempty=False) == [] + with pytest.raises(PTErr): + parse_permission_tokens([]) # default requires at least one + + +# --- keys ------------------------------------------------------------------- + + +@respx.mock +def test_keys_list_json(logged_in, runner): + respx.get(f"{BASE}/api/keys").mock( + return_value=httpx.Response( + 200, + json=[{"id": "k1", "name": "ci", "permissions": ["events:add"], "created_at": "t", "revoked_at": None}], + ) + ) + result = runner.invoke(app, ["--json", "keys", "list"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["keys"][0]["id"] == "k1" + + +@respx.mock +def test_keys_create_generates_secret_and_posts_it(logged_in, runner): + respx.get(f"{BASE}/api/keys").mock(return_value=httpx.Response(200, json=[])) # no name collision + route = respx.post(f"{BASE}/api/keys").mock( + return_value=httpx.Response(201, json={"id": "k9", "name": "ci-bot", + "permissions": ["events:read", "events:add", "keys:read"], "created_at": "t"}) + ) + # compact --add tokens: `events:read.add` expands to events:read + events:add; `keys:read` too + result = runner.invoke(app, ["--json", "keys", "create", "ci-bot", "--add", "events:read.add,keys:read"]) + assert result.exit_code == 0, result.output + body = json.loads(route.calls.last.request.content) + assert body["name"] == "ci-bot" + assert body["permissions"] == ["events:add", "events:read", "keys:read"] # expanded + de-duped + sorted + assert re.fullmatch(r"[0-9a-f]{64}", body["key"]) # CLI-generated 64-hex secret + assert json.loads(result.stdout)["key"] == body["key"] # secret only in the JSON `key` field + + +def test_keys_create_rejects_unknown_permission(logged_in, runner): + result = runner.invoke(app, ["keys", "create", "k", "--add", "orgs:admin"]) + assert result.exit_code == 2 # orgs:admin is not assignable + + +def test_keys_create_rejects_human_only_permission(logged_in, runner): + # keys:update is human-only — rejected client-side for a key (exit 2), not a server 422. + result = runner.invoke(app, ["keys", "create", "k", "--add", "keys:update"]) + assert result.exit_code == 2 + + +def test_keys_create_rejects_malformed_token(logged_in, runner): + # missing colon / empty action → red error box, exit 2, before any mutation + assert runner.invoke(app, ["keys", "create", "k", "--add", "events"]).exit_code == 2 + assert runner.invoke(app, ["keys", "create", "k", "--add", "events:"]).exit_code == 2 + + +@respx.mock +def test_keys_create_name_collision(logged_in, runner): + respx.get(f"{BASE}/api/keys").mock(return_value=httpx.Response(200, json=[ + {"id": "k1", "name": "ci-bot", "permissions": [], "created_at": "t", "revoked_at": None}])) + result = runner.invoke(app, ["--json", "keys", "create", "ci-bot", "--add", "events:read"]) + assert result.exit_code == 2 + assert "already exists" in json.loads(result.stdout)["error"] + + +def _one_key(name="ci-bot", key_id="k1", revoked=False): + return [{"id": key_id, "name": name, "permissions": ["events:add"], "created_at": "t", + "revoked_at": ("t" if revoked else None)}] + + +@respx.mock +def test_keys_disable_by_name_json(logged_in, runner): + respx.get(f"{BASE}/api/keys").mock(return_value=httpx.Response(200, json=_one_key())) + route = respx.post(f"{BASE}/api/keys/k1/disable").mock(return_value=httpx.Response(200, json={})) + # name is resolved to the key id; --json non-tty + --yes proceeds (auto-skip rule). + result = runner.invoke(app, ["--json", "keys", "disable", "ci-bot", "--yes"]) + assert result.exit_code == 0, result.output + assert route.called # resolved "ci-bot" -> /keys/k1/disable + assert json.loads(result.stdout) == {"name": "ci-bot", "status": "disabled"} + + +@respx.mock +def test_keys_disable_already_disabled_is_noop(logged_in, runner): + respx.get(f"{BASE}/api/keys").mock(return_value=httpx.Response(200, json=_one_key(revoked=True))) + route = respx.post(f"{BASE}/api/keys/k1/disable").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["--json", "keys", "disable", "ci-bot"]) + assert result.exit_code == 0, result.output + assert not route.called # already disabled → no destructive call + assert json.loads(result.stdout) == {"name": "ci-bot", "status": "disabled"} + + +@respx.mock +def test_keys_disable_not_found_exits_6(logged_in, runner): + respx.get(f"{BASE}/api/keys").mock(return_value=httpx.Response(200, json=_one_key())) + result = runner.invoke(app, ["keys", "disable", "nope", "--yes"]) + assert result.exit_code == 6 + assert "no key named" in (result.stderr or result.output) + + +@respx.mock +def test_keys_disable_forbidden_exits_5(logged_in, runner): + respx.get(f"{BASE}/api/keys").mock(return_value=httpx.Response(200, json=_one_key())) + respx.post(f"{BASE}/api/keys/k1/disable").mock( + return_value=httpx.Response(403, json={"error": "key is part of the configuration"}) + ) + result = runner.invoke(app, ["keys", "disable", "ci-bot", "--yes"]) + assert result.exit_code == 5 + + +@respx.mock +def test_keys_regenerate_shows_new_secret(logged_in, runner): + respx.get(f"{BASE}/api/keys").mock(return_value=httpx.Response(200, json=_one_key())) + respx.post(f"{BASE}/api/keys/k1/regenerate").mock( + return_value=httpx.Response(200, json={"key": "a" * 64}) + ) + result = runner.invoke(app, ["--json", "keys", "regenerate", "ci-bot", "--yes"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == {"name": "ci-bot", "key": "a" * 64} + + +@respx.mock +def test_keys_list_box(logged_in, runner): + respx.get(f"{BASE}/api/keys").mock(return_value=httpx.Response(200, json=[ + {"id": "1f58376d", "name": "admin", "permissions": ["a", "b"], "created_at": "2026-06-18T05:14:00Z", "revoked_at": None}, + {"id": "9c22aa01", "name": "old-key", "permissions": ["x"], "created_at": "2026-06-10T00:00:00Z", "revoked_at": "2026-06-12T00:00:00Z"}, + ])) + result = runner.invoke(app, ["keys", "list"]) + assert result.exit_code == 0, result.output + assert "api keys" in result.stdout and "active first" in result.stdout + assert "admin" in result.stdout and "active" in result.stdout and "revoked" in result.stdout + assert result.stdout.index("admin") < result.stdout.index("old-key") # active sorts above revoked + # footer summary (stderr) counts by status + assert "2 keys" in (result.stderr or "") and "1 active" in (result.stderr or "") and "1 revoked" in (result.stderr or "") + + +@respx.mock +def test_keys_update_incremental_add(logged_in, runner): + # --add alone is INCREMENTAL — merged into the key's current grants (like users update). + respx.get(f"{BASE}/api/keys").mock(return_value=httpx.Response(200, json=[ + {"id": "k1", "name": "ci", "permissions": ["events:add"], "created_at": "t", "revoked_at": None}])) + route = respx.patch(f"{BASE}/api/keys/k1").mock( + return_value=httpx.Response(200, json={"id": "k1", "name": "ci", + "permissions": ["events:add", "users:read"], "created_at": "t", "revoked_at": None}) + ) + result = runner.invoke(app, ["--json", "keys", "update", "ci", "--add", "users:read", "--yes"]) + assert result.exit_code == 0, result.output + # current {events:add} ∪ {users:read} = sorted ["events:add", "users:read"] + assert json.loads(route.calls.last.request.content) == {"permissions": ["events:add", "users:read"]} + body = json.loads(result.stdout) + assert body["added"] == ["users:read"] and body["removed"] == [] + + +@respx.mock +def test_keys_update_incremental_remove(logged_in, runner): + respx.get(f"{BASE}/api/keys").mock(return_value=httpx.Response(200, json=[ + {"id": "k1", "name": "ci", "permissions": ["events:add", "events:read"], "created_at": "t", "revoked_at": None}])) + route = respx.patch(f"{BASE}/api/keys/k1").mock( + return_value=httpx.Response(200, json={"id": "k1", "name": "ci", + "permissions": ["events:read"], "created_at": "t", "revoked_at": None}) + ) + result = runner.invoke(app, ["--json", "keys", "update", "ci", "--remove", "events:add", "--yes"]) + assert result.exit_code == 0, result.output + assert json.loads(route.calls.last.request.content) == {"permissions": ["events:read"]} + + +@respx.mock +def test_keys_update_noop_skips_server(logged_in, runner): + # adding a grant the key already has → no-op: no PATCH, exit 0. + respx.get(f"{BASE}/api/keys").mock(return_value=httpx.Response(200, json=[ + {"id": "k1", "name": "ci", "permissions": ["events:add"], "created_at": "t", "revoked_at": None}])) + route = respx.patch(f"{BASE}/api/keys/k1").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["--json", "keys", "update", "ci", "--add", "events:add", "--yes"]) + assert result.exit_code == 0, result.output + assert not route.called + assert json.loads(result.stdout)["added"] == [] and json.loads(result.stdout)["removed"] == [] + + +def test_keys_update_requires_a_change_flag(logged_in, runner): + result = runner.invoke(app, ["keys", "update", "ci"]) + assert result.exit_code == 2 # nothing to update + + +@respx.mock +def test_keys_update_not_found_exits_6(logged_in, runner): + respx.get(f"{BASE}/api/keys").mock(return_value=httpx.Response(200, json=[ + {"id": "k1", "name": "ci", "permissions": [], "created_at": "t", "revoked_at": None}])) + result = runner.invoke(app, ["keys", "update", "nope", "--add", "events:read", "--yes"]) + assert result.exit_code == 6 + + +# --- queries ---------------------------------------------------------------- + + +@respx.mock +def test_query_list_unwraps_queries_key(logged_in, runner): + respx.get(f"{BASE}/api/queries").mock( + return_value=httpx.Response(200, json={"queries": [{"id": "q1", "name": "errs", "sql_text": "select 1"}]}) + ) + result = runner.invoke(app, ["--json", "query", "list"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["queries"][0]["id"] == "q1" + + +@respx.mock +def test_query_create_posts_body(logged_in, runner): + respx.get(f"{BASE}/api/queries").mock(return_value=httpx.Response(200, json={"queries": []})) # no collision + route = respx.post(f"{BASE}/api/queries").mock( + return_value=httpx.Response(201, json={"id": "q9", "name": "errs", "sql_text": "select 1", "params": []}) + ) + result = runner.invoke( + app, ["--json", "query", "create", "errs", "--sql", "select 1"] # name is positional now + ) + assert result.exit_code == 0, result.output + body = json.loads(route.calls.last.request.content) + assert body["name"] == "errs" + assert body["sql_text"] == "select 1" + assert body["params"] == [] # --param was removed; created queries carry no params + + +@respx.mock +def test_query_create_sql_from_file(logged_in, runner, tmp_path): + sql_file = tmp_path / "q.sql" + sql_file.write_text("select count(*) from analytics.events") + respx.get(f"{BASE}/api/queries").mock(return_value=httpx.Response(200, json={"queries": []})) + route = respx.post(f"{BASE}/api/queries").mock( + return_value=httpx.Response(201, json={"id": "q1", "name": "n", "sql_text": "x", "params": []}) + ) + result = runner.invoke(app, ["query", "create", "n", "--sql", f"@{sql_file}"]) + assert result.exit_code == 0, result.output + assert json.loads(route.calls.last.request.content)["sql_text"] == "select count(*) from analytics.events" + + +@respx.mock +def test_query_create_name_collision_exits_2(logged_in, runner): + respx.get(f"{BASE}/api/queries").mock(return_value=httpx.Response(200, json={"queries": [{"id": "q1", "name": "errs"}]})) + result = runner.invoke(app, ["--json", "query", "create", "errs", "--sql", "select 1"]) + assert result.exit_code == 2 + assert "already exists" in json.loads(result.stdout)["error"] + + +@respx.mock +def test_query_run_inline_renders_rows(logged_in, runner): + respx.post(f"{BASE}/api/queries/run").mock( + return_value=httpx.Response(200, json={"columns": [{"name": "n", "type": "int"}], "rows": [[5]], "truncated": False, "elapsed_ms": 3}) + ) + result = runner.invoke(app, ["--json", "query", "run", "--sql", "select 5 as n"]) + assert result.exit_code == 0, result.output + data = json.loads(result.stdout) + assert data["rows"] == [[5]] + assert data["elapsed_ms"] == 3 + + +def test_query_run_requires_sql_or_saved(logged_in, runner): + result = runner.invoke(app, ["query", "run"]) + assert result.exit_code == 2 + + +@respx.mock +def test_query_run_coerces_param_values(logged_in, runner): + route = respx.post(f"{BASE}/api/queries/run").mock( + return_value=httpx.Response(200, json={"columns": [], "rows": [], "truncated": False, "elapsed_ms": 1}) + ) + result = runner.invoke(app, ["--json", "query", "run", "--sql", "select $1,$2,$3", "--param", "5", "--param", "true", "--param", "hi"]) + assert result.exit_code == 0, result.output + assert json.loads(route.calls.last.request.content)["params"] == [5, True, "hi"] + + +@respx.mock +def test_query_run_readonly_violation_403(logged_in, runner): + respx.post(f"{BASE}/api/queries/run").mock( + return_value=httpx.Response(403, json={"error": "permission denied — only analytics.* views are queryable"}) + ) + result = runner.invoke(app, ["query", "run", "--sql", "select * from pg_user"]) + assert result.exit_code == 5 + + +@respx.mock +def test_query_schema_json_splits_nullable(logged_in, runner): + respx.get(f"{BASE}/api/queries/schema").mock( + return_value=httpx.Response(200, json={"schema": "analytics", "tables": [ + {"name": "events", "columns": [{"name": "id", "type": "int"}, {"name": "tool_name", "type": "string?"}]}]}) + ) + result = runner.invoke(app, ["--json", "query", "schema"]) + assert result.exit_code == 0, result.output + doc = json.loads(result.stdout) + assert doc["schema"] == "analytics" + cols = {c["column"]: c for c in doc["columns"]} + assert cols["id"] == {"table": "events", "column": "id", "type": "int", "nullable": False} + assert cols["tool_name"]["type"] == "string" and cols["tool_name"]["nullable"] is True # ? split off + + +@respx.mock +def test_query_schema_table_filter(logged_in, runner): + respx.get(f"{BASE}/api/queries/schema").mock( + return_value=httpx.Response(200, json={"schema": "analytics", "tables": [ + {"name": "events", "columns": [{"name": "id", "type": "int"}]}, + {"name": "evaluations", "columns": [{"name": "status", "type": "string"}]}]}) + ) + result = runner.invoke(app, ["--json", "query", "schema", "evaluations"]) + assert result.exit_code == 0, result.output + cols = json.loads(result.stdout)["columns"] + assert {c["table"] for c in cols} == {"evaluations"} # filtered to one table + + +@respx.mock +def test_query_schema_table_not_found_exits_6(logged_in, runner): + respx.get(f"{BASE}/api/queries/schema").mock( + return_value=httpx.Response(200, json={"schema": "analytics", "tables": [{"name": "events", "columns": []}]})) + result = runner.invoke(app, ["query", "schema", "nope"]) + assert result.exit_code == 6 + + +# --- queries: name-referenced show / run / update / delete ------------------ + + +@respx.mock +def test_query_show_by_name_json(logged_in, runner): + respx.get(f"{BASE}/api/queries").mock(return_value=httpx.Response(200, json={"queries": [ + {"id": "q1", "name": "errs", "description": "d", "sql_text": "select 1", "created_by": "system", "created_at": "t"}]})) + result = runner.invoke(app, ["--json", "query", "show", "errs"]) # by NAME, not id + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["sql_text"] == "select 1" + + +@respx.mock +def test_query_show_not_found_exits_6(logged_in, runner): + respx.get(f"{BASE}/api/queries").mock(return_value=httpx.Response(200, json={"queries": [{"id": "q1", "name": "errs"}]})) + result = runner.invoke(app, ["query", "show", "nope"]) + assert result.exit_code == 6 + + +@respx.mock +def test_query_run_saved_by_positional_name(logged_in, runner): + respx.get(f"{BASE}/api/queries").mock(return_value=httpx.Response(200, json={"queries": [ + {"id": "q1", "name": "errs", "sql_text": "select 1"}]})) + route = respx.post(f"{BASE}/api/queries/run").mock( + return_value=httpx.Response(200, json={"columns": [], "rows": [], "truncated": False, "elapsed_ms": 1})) + result = runner.invoke(app, ["--json", "query", "run", "errs", "--arg", "prod"]) # positional name, no --saved + assert result.exit_code == 0, result.output + body = json.loads(route.calls.last.request.content) + assert body["query_id"] == "q1" # name resolved to id + assert body["params"] == ["prod"] + + +def test_query_run_requires_name_or_sql(logged_in, runner): + assert runner.invoke(app, ["query", "run"]).exit_code == 2 # neither + assert runner.invoke(app, ["query", "run", "errs", "--sql", "select 1"]).exit_code == 2 # both + + +@respx.mock +def test_query_run_saved_not_found_exits_6(logged_in, runner): + respx.get(f"{BASE}/api/queries").mock(return_value=httpx.Response(200, json={"queries": [{"id": "q1", "name": "errs"}]})) + result = runner.invoke(app, ["query", "run", "ghost"]) + assert result.exit_code == 6 + + +@respx.mock +def test_query_run_exec_error_is_clean(logged_in, runner): + respx.post(f"{BASE}/api/queries/run").mock( + return_value=httpx.Response(400, json={"error": "Syntax error: unexpected token near 'FORM'"})) + result = runner.invoke(app, ["query", "run", "--sql", "SELECT * FORM events"]) + assert result.exit_code == 1 + # The underlying DB error is surfaced directly (no opaque "query failed" wrapper). + assert "Syntax error" in result.stderr + + +@respx.mock +def test_query_update_rename_collision_exits_2(logged_in, runner): + respx.get(f"{BASE}/api/queries").mock(return_value=httpx.Response(200, json={"queries": [ + {"id": "q1", "name": "errs", "sql_text": "select 1"}, {"id": "q2", "name": "taken", "sql_text": "select 2"}]})) + result = runner.invoke(app, ["--json", "query", "update", "errs", "--name", "taken", "--yes"]) + assert result.exit_code == 2 + assert "already exists" in json.loads(result.stdout)["error"] + + +@respx.mock +def test_query_update_noop_makes_no_put(logged_in, runner): + # --description equal to current → nothing actually changes → no PUT, exit 0. + respx.get(f"{BASE}/api/queries").mock(return_value=httpx.Response(200, json={"queries": [ + {"id": "q1", "name": "errs", "description": "same", "sql_text": "select 1", "params": []}]})) + put = respx.put(f"{BASE}/api/queries/q1").mock(return_value=httpx.Response(200, json={"id": "q1"})) + result = runner.invoke(app, ["--json", "query", "update", "errs", "--description", "same", "--yes"]) + assert result.exit_code == 0, result.output + assert not put.called + + +@respx.mock +def test_query_update_by_name_keeps_omitted_fields(logged_in, runner): + respx.get(f"{BASE}/api/queries").mock(return_value=httpx.Response(200, json={"queries": [ + {"id": "q1", "name": "errs", "description": "old desc", "sql_text": "select 1", + "params": [{"name": "e", "type": "text"}]}]})) + route = respx.put(f"{BASE}/api/queries/q1").mock( + return_value=httpx.Response(200, json={"id": "q1", "name": "errs", "sql_text": "select 2"})) + result = runner.invoke(app, ["--json", "query", "update", "errs", "--sql", "select 2", "--yes"]) + assert result.exit_code == 0, result.output + sent = json.loads(route.calls.last.request.content) + assert sent["sql_text"] == "select 2" # changed + assert sent["name"] == "errs" # kept + assert sent["description"] == "old desc" # kept + assert sent["params"] == [{"name": "e", "type": "text"}] # kept + + +def test_query_update_noop_rejected(logged_in, runner): + # No fields → usage error before any read/write (no routes mocked). + result = runner.invoke(app, ["query", "update", "errs", "--yes"]) + assert result.exit_code == 2 + + +@respx.mock +def test_query_update_sql_from_stdin_reads_it_once(logged_in, runner): + # `--sql @-` is stdin, which drains on the first read. Reading it a second time for + # the request body would send "" — a query silently emptied at exit 0, while the + # change detection had compared the real text. + respx.get(f"{BASE}/api/queries").mock(return_value=httpx.Response(200, json={"queries": [ + {"id": "q1", "name": "errs", "description": "d", "sql_text": "select 1", "params": []}]})) + route = respx.put(f"{BASE}/api/queries/q1").mock( + return_value=httpx.Response(200, json={"id": "q1", "name": "errs", "sql_text": "select 2"})) + result = runner.invoke(app, ["--json", "query", "update", "errs", "--sql", "@-", "--yes"], + input="select 2 from analytics.events") + assert result.exit_code == 0, result.output + assert json.loads(route.calls.last.request.content)["sql_text"] == "select 2 from analytics.events" + + +@respx.mock +def test_query_delete_by_name(logged_in, runner): + respx.get(f"{BASE}/api/queries").mock(return_value=httpx.Response(200, json={"queries": [{"id": "q1", "name": "errs"}]})) + route = respx.delete(f"{BASE}/api/queries/q1").mock(return_value=httpx.Response(200, json={"deleted": True})) + result = runner.invoke(app, ["--json", "query", "delete", "errs", "--yes"]) + assert result.exit_code == 0, result.output + assert route.called + body = json.loads(result.stdout) + assert body["deleted"] is True and body["name"] == "errs" + + +@respx.mock +def test_query_delete_not_found_exits_6(logged_in, runner): + respx.get(f"{BASE}/api/queries").mock(return_value=httpx.Response(200, json={"queries": [{"id": "q1", "name": "errs"}]})) + result = runner.invoke(app, ["query", "delete", "ghost", "--yes"]) + assert result.exit_code == 6 diff --git a/fp-cli/tests/test_list.py b/fp-cli/tests/test_list.py new file mode 100644 index 000000000..829e0b88a --- /dev/null +++ b/fp-cli/tests/test_list.py @@ -0,0 +1,59 @@ +"""`fp list <thing>` — value discovery behind the dashboard's filter dropdowns.""" + +from __future__ import annotations + +import json + +import httpx +import pytest +import respx + +from fp_cli.app import app + +BASE = "http://dash.test" + +# friendly `list` name -> the endpoint it must hit +_CASES = { + "envs": "/api/events/environments", + "agents": "/api/events/agent_ids", + "event_types": "/api/events/event_types", + "score_filters": "/api/evaluations/score-keys", + "models": "/api/events/models", + "hooks": "/api/events/hook_names", + "tools": "/api/events/tool_names", + "error_types": "/api/events/error_types", +} + + +@pytest.mark.parametrize("name,path", list(_CASES.items())) +@respx.mock +def test_list_hits_right_endpoint_and_wraps(logged_in, runner, name, path): + route = respx.get(f"{BASE}{path}").mock(return_value=httpx.Response(200, json=["a", "b"])) + result = runner.invoke(app, ["--json", "list", name]) + assert result.exit_code == 0, result.output + assert route.called # the friendly name resolved to the correct endpoint + assert json.loads(result.stdout) == {"kind": name, "values": ["a", "b"]} + + +@respx.mock +def test_list_empty_is_clean(logged_in, runner): + respx.get(f"{BASE}/api/events/models").mock(return_value=httpx.Response(200, json=[])) + result = runner.invoke(app, ["--json", "list", "models"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["values"] == [] + + +def test_list_unknown_kind_is_usage_error(logged_in, runner): + # An undefined subcommand is a clean usage error, not a crash. + result = runner.invoke(app, ["list", "bogus"]) + assert result.exit_code == 2 + + +@respx.mock +def test_list_forbidden_exits_5(logged_in, runner): + # score_filters needs evaluations:read; a 403 maps to the forbidden exit code. + respx.get(f"{BASE}/api/evaluations/score-keys").mock( + return_value=httpx.Response(403, json={"error": "forbidden"}) + ) + result = runner.invoke(app, ["list", "score_filters"]) + assert result.exit_code == 5 diff --git a/fp-cli/tests/test_multivalue.py b/fp-cli/tests/test_multivalue.py new file mode 100644 index 000000000..4af981783 --- /dev/null +++ b/fp-cli/tests/test_multivalue.py @@ -0,0 +1,183 @@ +"""Multi-value option support. + +Covers the reusable `collect_multi` normalizer (repeated flags + comma-separated → one +flat, trimmed, de-duped, order-preserving list) and its end-to-end wiring on the `events` +command's `--session-id` / `--env` / `--event-type` / `--agent-id` filters (each serialized +to a CSV `IN(...)` on the wire, single value back-compatible). +""" + +from __future__ import annotations + +import httpx +import respx + +from fp_cli._context import collect_multi +from fp_cli.app import app + +BASE = "http://dash.test" + + +# --- the pure normalizer ---------------------------------------------------- + + +def test_collect_multi_single_value(): + # Always an array internally, even for one value. + assert collect_multi(["prod"]) == ["prod"] + + +def test_collect_multi_repeated_flags(): + assert collect_multi(["prod", "staging"]) == ["prod", "staging"] + + +def test_collect_multi_comma_separated(): + assert collect_multi(["prod,staging"]) == ["prod", "staging"] + + +def test_collect_multi_repeated_and_comma_combined(): + assert collect_multi(["prod,staging", "dev"]) == ["prod", "staging", "dev"] + + +def test_collect_multi_trims_whitespace(): + assert collect_multi(["prod, staging"]) == ["prod", "staging"] + assert collect_multi([" prod ", " dev"]) == ["prod", "dev"] + + +def test_collect_multi_drops_empty_from_trailing_comma(): + assert collect_multi(["prod,"]) == ["prod"] + assert collect_multi(["prod,,staging"]) == ["prod", "staging"] + assert collect_multi([","]) is None + + +def test_collect_multi_dedup_preserves_first_seen_order(): + assert collect_multi(["prod", "prod"]) == ["prod"] + assert collect_multi(["b", "a", "b", "c", "a"]) == ["b", "a", "c"] + assert collect_multi(["prod,staging", "staging,prod"]) == ["prod", "staging"] + + +def test_collect_multi_empty_and_none(): + assert collect_multi(None) is None + assert collect_multi([]) is None + assert collect_multi(["", " "]) is None + + +# --- end-to-end on the events command (what reaches the wire) --------------- + + +def _captured_params(logged_in, runner, argv): + """Run `events` with argv and return the query params the CLI sent. + + `events` routes to the light feed (/api/events/summary) by default and to the full feed + (/api/events) only when payload is needed (e.g. --session-id). Both accept the identical + query surface, so mock BOTH and read params from whichever the CLI actually called. + """ + resp = httpx.Response(200, json={"events": [], "next_cursor": None}) + light = respx.get(f"{BASE}/api/events/summary").mock(return_value=resp) + full = respx.get(f"{BASE}/api/events").mock(return_value=resp) + result = runner.invoke(app, argv) + assert result.exit_code == 0, result.output + route = full if full.called else light + return dict(route.calls.last.request.url.params) + + +@respx.mock +def test_events_env_single_value_unchanged(logged_in, runner): + # Backward compatibility: one value serializes to a bare `environment=prod`. + params = _captured_params(logged_in, runner, ["events", "--env", "prod"]) + assert params["environment"] == "prod" + + +@respx.mock +def test_events_env_repeated_flags_merge(logged_in, runner): + # The original failing case: repeated flags must keep BOTH, not last-wins. + params = _captured_params(logged_in, runner, ["events", "--env", "prod", "--env", "staging"]) + assert params["environment"] == "prod,staging" + + +@respx.mock +def test_events_env_comma_separated(logged_in, runner): + params = _captured_params(logged_in, runner, ["events", "--env", "prod,staging"]) + assert params["environment"] == "prod,staging" + + +@respx.mock +def test_events_env_combined_and_deduped(logged_in, runner): + params = _captured_params( + logged_in, runner, ["events", "--env", "prod,staging", "--env", "dev", "--env", "prod"] + ) + assert params["environment"] == "prod,staging,dev" + + +@respx.mock +def test_events_all_four_filters_multi(logged_in, runner): + # session-id / agent-id / event-type / env all become CSV IN(...) on the wire. + params = _captured_params( + logged_in, + runner, + [ + "events", + "--session-id", "s1", "--session-id", "s2", + "--agent-id", "a1,a2", + "--event-type", "tool_use", "--event-type", "tool_result", + "--env", "prod", + ], + ) + assert params["session_id"] == "s1,s2" + assert params["agent_id"] == "a1,a2" + assert params["event_type"] == "tool_use,tool_result" + assert params["environment"] == "prod" + + +# --- end-to-end on the sessions command (/api/sessions) --------------------- + + +def _captured_session_params(logged_in, runner, argv): + """Run `sessions` with argv and return the query params the CLI sent to /api/sessions.""" + route = respx.get(f"{BASE}/api/sessions").mock( + return_value=httpx.Response(200, json={"sessions": [], "next_cursor": None}) + ) + result = runner.invoke(app, argv) + assert result.exit_code == 0, result.output + return dict(route.calls.last.request.url.params) + + +@respx.mock +def test_sessions_status_repeated_flags_merge(logged_in, runner): + # --status was the one that 400'd as multi-value on the old endpoint; now CSV IN. + params = _captured_session_params( + logged_in, runner, ["sessions", "--status", "error", "--status", "timeout"] + ) + assert params["status"] == "error,timeout" + + +@respx.mock +def test_sessions_all_four_filters_multi(logged_in, runner): + # env / status / agent-id / session-id all become CSV IN(...) on the wire. + params = _captured_session_params( + logged_in, + runner, + [ + "sessions", + "--env", "prod,staging", + "--status", "done", "--status", "error", + "--agent-id", "a1", "--agent-id", "a2", + "--session-id", "s1,s2", + ], + ) + assert params["environment"] == "prod,staging" + assert params["status"] == "done,error" + assert params["agent_id"] == "a1,a2" + assert params["session_id"] == "s1,s2" + + +@respx.mock +def test_sessions_single_value_unchanged(logged_in, runner): + # Backward compatibility: one value serializes to a bare param. + params = _captured_session_params(logged_in, runner, ["sessions", "--env", "prod"]) + assert params["environment"] == "prod" + + +def test_sessions_bad_status_is_usage_error(logged_in, runner): + # An invalid --status value is caught client-side (exit 2), not a server 400. + assert runner.invoke(app, ["sessions", "--status", "bogus"]).exit_code == 2 + # ...even when mixed with a valid one. + assert runner.invoke(app, ["sessions", "--status", "done,bogus"]).exit_code == 2 diff --git a/fp-cli/tests/test_no_customer_identifiers.py b/fp-cli/tests/test_no_customer_identifiers.py new file mode 100644 index 000000000..c6c564e39 --- /dev/null +++ b/fp-cli/tests/test_no_customer_identifiers.py @@ -0,0 +1,168 @@ +"""This package is PUBLIC and its wheel is published to PyPI. + +A real customer's tenant slug and company name reached this tree once, in a source +comment that shipped in the wheel and in four test files. It came across in a bulk +copy out of a private monorepo, where naming a live tenant in a fixture was harmless. + +Fixtures must use obviously-fake names. This is the tripwire. + +Customer names are held as SHA-256 digests, never in the clear. A deny-list that +spells out the name it exists to keep out of a public wheel publishes that name just +as surely as the fixture did — and this file ships in the sdist. Our OWN names stay +readable: they are already in LICENSE, SECURITY.md and package.json, so there is +nothing to withhold, and a contributor who trips over one needs to see which it was. + +The match is over SUBSTRINGS of each token, not whole tokens, because the original +leak was both a bare tenant slug and a longer company name built from that same slug +— one name inside the other. To add an identifier: + + python3 -c 'import hashlib,sys;print(hashlib.sha256(sys.argv[1].lower().encode()).hexdigest())' NAME +""" + +from __future__ import annotations + +import functools +import hashlib +import pathlib +import re + +import fp_cli + +PKG = pathlib.Path(fp_cli.__file__).resolve().parent +ROOT = PKG.parent + +# Our own organisation names — public in this repo already, so in the clear. A fixture +# must still not use them: they identify a real tenant on a real deployment. +FORBIDDEN_OWN = { + "exosphere", + "exospherehost", +} + +# Customer / vendor identifiers, digest → what it is (never the name itself). +FORBIDDEN_DIGESTS = { + "140bd3c7a8606c97e18fb1f01c3a94f558eab6e2c5b27a56f9c3f5a940d8e2fd": "a customer's tenant slug", +} + +# The vocabulary fixtures are supposed to use. +SANCTIONED_FIXTURE_ORGS = {"acme", "globex", "example", "initech", "umbrella"} + +_TOKEN = re.compile(r"[a-z0-9]+") +_URL = re.compile(r"https?://([a-z0-9.-]+)", re.I) + +# Substring lengths considered when hashing. The floor keeps the scan off two- and +# three-letter noise; the ceiling bounds the work on long tokens (hex digests, base64). +_MIN_LEN = 5 +_MAX_LEN = 24 + + +@functools.lru_cache(maxsize=None) +def _digest(text: str) -> str: + return hashlib.sha256(text.encode()).hexdigest() + + +def _hashed_hits(text: str, needles: frozenset[str] | None = None) -> set[str]: + """Digests from ``needles`` whose plaintext appears anywhere in ``text``. + + ``text`` need not be lowercased by the caller. Returns digests, not matched text: + a failure message must not echo the identifier into a public CI log. + """ + want = FORBIDDEN_DIGESTS.keys() if needles is None else needles + found = set() + for token in set(_TOKEN.findall(text.lower())): + for start in range(len(token)): + stop = min(len(token), start + _MAX_LEN) + for end in range(start + _MIN_LEN, stop + 1): + d = _digest(token[start:end]) + if d in want: + found.add(d) + return found + + +def _sources() -> list[pathlib.Path]: + out = [] + for base in (PKG, ROOT / "tests"): + out.extend(p for p in base.rglob("*.py") if "__pycache__" not in p.parts) + for name in ("README.md", "CHANGELOG.md"): + p = ROOT / name + if p.is_file(): + out.append(p) + for p in (ROOT / "skill").rglob("*"): + if p.is_file() and p.suffix in {".md", ".yaml", ".yml"}: + out.append(p) + return out + + +def _scannable() -> list[pathlib.Path]: + """Everything but this file, which names our own orgs to deny them.""" + return [p for p in _sources() if p.name != pathlib.Path(__file__).name] + + +def test_no_real_customer_or_vendor_identifiers(): + hits = [] + for p in _scannable(): + for i, line in enumerate(p.read_text(encoding="utf-8", errors="replace").split("\n"), 1): + lowered = line.lower() + for needle in FORBIDDEN_OWN: + if needle in lowered: + hits.append(f"{p.relative_to(ROOT)}:{i}: {needle}") + # Report the location and what class of identifier it is — never the name. + for digest in _hashed_hits(line): + hits.append(f"{p.relative_to(ROOT)}:{i}: {FORBIDDEN_DIGESTS[digest]}") + assert not hits, ( + "real organisation names must not appear in a public package — use a fixture " + f"name such as {sorted(SANCTIONED_FIXTURE_ORGS)}:\n " + "\n ".join(hits) + ) + + +def test_the_scan_actually_has_files_to_scan(): + """Keeps the assertion above from passing vacuously if the layout moves.""" + files = _sources() + assert len(files) > 40, f"only {len(files)} files scanned — the walk is not finding the package" + + +def test_the_hashed_scan_matches_substrings_and_only_them(): + """The digests are opaque, so prove the matcher on a planted, invented name. + + Without this, an off-by-one in the substring window turns the whole hashed + deny-list into an assertion that passes because it matches nothing. + """ + planted = frozenset({_digest("quuxcorp")}) + assert _hashed_hits("slug: quuxcorp", planted) == planted # bare token + assert _hashed_hits('name: "QuuxcorpInc"', planted) == planted # inside a longer name + assert _hashed_hits("host: quuxcorp-prod.example.com", planted) == planted + assert not _hashed_hits("slug: acme, name: Globex Corp", planted) # sanctioned fixtures + assert not _hashed_hits("quux corp", planted) # not one token + + +def test_this_file_does_not_name_the_customers_it_denies(): + """The deny-list must not restate, in the clear, what it holds as a digest. + + ``_scannable()`` skips this file so the ``FORBIDDEN_OWN`` literals above do not + trip the scan on themselves. That exemption is about OUR names, which are public + in this repo already. It must never extend to a customer's: this file ships in + the sdist, so a name written here is published exactly like the fixture that + started all this. It needs its own test precisely because the exemption is what + blinds the main scan to it. + """ + src = pathlib.Path(__file__) + hits = [ + f"{src.name}:{i}: {FORBIDDEN_DIGESTS[digest]}" + for i, line in enumerate(src.read_text(encoding="utf-8", errors="replace").split("\n"), 1) + for digest in _hashed_hits(line) + ] + assert not hits, ( + "this file names, in the clear, an identifier it exists to keep out of a " + "public package:\n " + "\n ".join(hits) + ) + + +def test_no_internal_hostnames_leaked(): + """A customer's deployment hostname identifies them as surely as their name.""" + hits = [] + for p in _scannable(): + for i, line in enumerate(p.read_text(encoding="utf-8", errors="replace").split("\n"), 1): + for host in _URL.findall(line): + labels = host.lower().split(".") + if any(label in FORBIDDEN_OWN for label in labels) or _hashed_hits(host): + hits.append(f"{p.relative_to(ROOT)}:{i}") + assert not hits, f"internal/customer hostnames in a public package: {hits}" diff --git a/fp-cli/tests/test_operator.py b/fp-cli/tests/test_operator.py new file mode 100644 index 000000000..2cbdc8bb6 --- /dev/null +++ b/fp-cli/tests/test_operator.py @@ -0,0 +1,492 @@ +"""Operator domains: users, settings.""" + +from __future__ import annotations + +import json + +import httpx +import respx + +from fp_cli.app import app + +BASE = "http://dash.test" + + +def _user(**over): + """A DashboardUser-shaped dict with sensible defaults, overridable per field.""" + base = { + "id": "u1", "email": "a@test", "permissions": [], "permission_set": None, + "permission_added": [], "permission_removed": [], "disabled_at": None, + "is_protected": False, "created_at": "2026-06-25T08:00:00Z", + "updated_at": "2026-06-25T08:00:00Z", + } + base.update(over) + return base + + +# --- users list ------------------------------------------------------------- + + +@respx.mock +def test_users_list_json(logged_in, runner): + respx.get(f"{BASE}/api/users").mock( + return_value=httpx.Response(200, json=[ + _user(id="u1", email="a@test", permissions=["events:read"], permission_set="standard"), + ]) + ) + result = runner.invoke(app, ["--json", "users", "list"]) + assert result.exit_code == 0, result.output + body = json.loads(result.stdout)["users"][0] + assert body["email"] == "a@test" + assert body["created_at"] == "2026-06-25T08:00:00Z" # join date carried through + + +@respx.mock +def test_users_list_sorts_active_first(logged_in, runner): + respx.get(f"{BASE}/api/users").mock( + return_value=httpx.Response(200, json=[ + _user(id="u2", email="b@test", disabled_at="2026-01-01T00:00:00Z"), # disabled first + _user(id="u1", email="a@test", disabled_at=None), + ]) + ) + result = runner.invoke(app, ["--json", "users", "list"]) + emails = [u["email"] for u in json.loads(result.stdout)["users"]] + assert emails == ["a@test", "b@test"] # active first, disabled last + + +@respx.mock +def test_users_list_active_only_hides_disabled(logged_in, runner): + respx.get(f"{BASE}/api/users").mock( + return_value=httpx.Response(200, json=[ + _user(id="u1", email="a@test", disabled_at=None), + _user(id="u2", email="b@test", disabled_at="2026-01-01T00:00:00Z"), + ]) + ) + result = runner.invoke(app, ["--json", "users", "list", "--active-only"]) + assert [u["email"] for u in json.loads(result.stdout)["users"]] == ["a@test"] + + +@respx.mock +def test_users_list_human_renders_boxed(logged_in, runner): + respx.get(f"{BASE}/api/users").mock( + return_value=httpx.Response(200, json=[ + _user(id="u1", email="root@test", permissions=["events:read"] * 9, + permission_set="admin", is_protected=True), + _user(id="u2", email="off@test", disabled_at="2026-01-01T00:00:00Z", permission_set="standard"), + ]) + ) + result = runner.invoke(app, ["users", "list"]) + assert result.exit_code == 0, result.output + out = result.stdout + result.stderr + assert "users" in out + assert "active" in out and "disabled" in out # status words + footer + assert "protected" in out # footer protected segment + + +# --- users show ------------------------------------------------------------- + + +@respx.mock +def test_users_show_by_email(logged_in, runner): + respx.get(f"{BASE}/api/users").mock( + return_value=httpx.Response(200, json=[ + _user(id="u1", email="dev@example.com", permissions=["events:read", "keys:create"], + permission_set="standard"), + ]) + ) + result = runner.invoke(app, ["--json", "users", "show", "dev@example.com"]) + assert result.exit_code == 0, result.output + body = json.loads(result.stdout) + assert body["email"] == "dev@example.com" + assert sorted(body["permissions"]) == ["events:read", "keys:create"] + + +@respx.mock +def test_users_show_by_id(logged_in, runner): + respx.get(f"{BASE}/api/users").mock( + return_value=httpx.Response(200, json=[_user(id="abc-123", email="dev@example.com")]) + ) + result = runner.invoke(app, ["--json", "users", "show", "abc-123"]) # id handle still works + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["email"] == "dev@example.com" + + +@respx.mock +def test_users_show_not_found_exits_6(logged_in, runner): + respx.get(f"{BASE}/api/users").mock(return_value=httpx.Response(200, json=[_user(email="a@test")])) + result = runner.invoke(app, ["users", "show", "nobody@test"]) + assert result.exit_code == 6 + + +# --- users create ----------------------------------------------------------- + + +@respx.mock +def test_users_create_positional_email_and_set(logged_in, runner): + respx.get(f"{BASE}/api/users").mock(return_value=httpx.Response(200, json=[])) # no collision + route = respx.post(f"{BASE}/api/users").mock( + return_value=httpx.Response(201, json=_user(id="u9", email="dev@example.com", + permissions=["events:read"], permission_set="standard")) + ) + result = runner.invoke(app, ["--json", "users", "create", "dev@example.com", + "--permission-set", "standard", "--add", "keys:create"]) + assert result.exit_code == 0, result.output + body = json.loads(route.calls.last.request.content) + assert body["email"] == "dev@example.com" + assert body["permission_set"] == "standard" + assert body["permission_added"] == ["keys:create"] + + +@respx.mock +def test_users_create_dotted_tokens_expand(logged_in, runner): + respx.get(f"{BASE}/api/users").mock(return_value=httpx.Response(200, json=[])) + route = respx.post(f"{BASE}/api/users").mock( + return_value=httpx.Response(201, json=_user(id="u9", email="dev@example.com")) + ) + result = runner.invoke(app, ["--json", "users", "create", "dev@example.com", + "--add", "keys:create.regenerate", "--remove", "alerts:read"]) + assert result.exit_code == 0, result.output + body = json.loads(route.calls.last.request.content) + assert body["permission_added"] == ["keys:create", "keys:regenerate"] # dotted expanded + assert body["permission_removed"] == ["alerts:read"] + + +def test_users_create_rejects_unknown_permission(logged_in, runner): + result = runner.invoke(app, ["users", "create", "a@b.com", "--add", "bogus:perm"]) + assert result.exit_code == 2 + + +def test_users_create_email_is_positional_only(logged_in, runner): + # --email was removed — the email is the positional argument now. + result = runner.invoke(app, ["users", "create", "--email", "c@d.com"]) + assert result.exit_code == 2 # unknown option + + +def test_users_create_requires_email(logged_in, runner): + result = runner.invoke(app, ["users", "create", "--permission-set", "standard"]) + assert result.exit_code == 2 # missing required EMAIL argument + + +@respx.mock +def test_users_create_email_collision_exits_2(logged_in, runner): + respx.get(f"{BASE}/api/users").mock( + return_value=httpx.Response(200, json=[_user(id="u1", email="dev@example.com")]) + ) + result = runner.invoke(app, ["--json", "users", "create", "dev@example.com", "--permission-set", "standard"]) + assert result.exit_code == 2 + assert "already exists" in json.loads(result.stdout)["error"] + + +# --- users update: incremental + role assign + diff ------------------------- + + +@respx.mock +def test_users_update_add_is_incremental(logged_in, runner): + # current state: a set + existing overrides — the merge must keep them. + respx.get(f"{BASE}/api/users").mock(return_value=httpx.Response(200, json=[ + _user(id="u1", email="a@b.com", permissions=["events:add"], permission_set="standard", + permission_added=["events:add"], permission_removed=["queries:run"]), + ])) + route = respx.put(f"{BASE}/api/users/u1").mock( + return_value=httpx.Response(200, json=_user(id="u1", email="a@b.com", + permissions=["events:add", "keys:create"])) + ) + result = runner.invoke(app, ["--json", "users", "update", "a@b.com", "--add", "keys:create", "--yes"]) + assert result.exit_code == 0, result.output + sent = json.loads(route.calls.last.request.content) + assert sent["permission_set"] == "standard" # set preserved + assert sorted(sent["permission_added"]) == ["events:add", "keys:create"] # merged, not replaced + assert sent["permission_removed"] == ["queries:run"] # preserved + + +@respx.mock +def test_users_update_remove_unsuppresses_and_drops_add(logged_in, runner): + respx.get(f"{BASE}/api/users").mock(return_value=httpx.Response(200, json=[ + _user(id="u1", email="a@b.com", permissions=["keys:create"], permission_set="standard", + permission_added=["keys:create"], permission_removed=[]), + ])) + route = respx.put(f"{BASE}/api/users/u1").mock( + return_value=httpx.Response(200, json=_user(id="u1", email="a@b.com", permissions=[])) + ) + result = runner.invoke(app, ["--json", "users", "update", "a@b.com", "--remove", "keys:create", "--yes"]) + assert result.exit_code == 0, result.output + sent = json.loads(route.calls.last.request.content) + assert sent["permission_added"] == [] # dropped from added + assert sent["permission_removed"] == ["keys:create"] # and suppressed + + +@respx.mock +def test_users_update_permission_set_assign(logged_in, runner): + respx.get(f"{BASE}/api/users").mock(return_value=httpx.Response(200, json=[ + _user(id="u1", email="a@b.com", permissions=[], permission_set="read-only"), + ])) + route = respx.put(f"{BASE}/api/users/u1").mock( + return_value=httpx.Response(200, json=_user(id="u1", email="a@b.com", permission_set="admin")) + ) + result = runner.invoke(app, ["--json", "users", "update", "a@b.com", + "--permission-set", "admin", "--add", "events:add", "--yes"]) + assert result.exit_code == 0, result.output + sent = json.loads(route.calls.last.request.content) + assert sent == {"permission_set": "admin", "permission_added": ["events:add"], "permission_removed": []} + + +@respx.mock +def test_users_update_json_includes_diff(logged_in, runner): + respx.get(f"{BASE}/api/users").mock(return_value=httpx.Response(200, json=[ + _user(id="u1", email="a@b.com", permissions=["events:read"]), + ])) + respx.put(f"{BASE}/api/users/u1").mock( + return_value=httpx.Response(200, json=_user(id="u1", email="a@b.com", + permissions=["events:read", "keys:create"])) + ) + result = runner.invoke(app, ["--json", "users", "update", "a@b.com", "--add", "keys:create", "--yes"]) + assert result.exit_code == 0, result.output + body = json.loads(result.stdout) + assert body["added"] == ["keys:create"] + assert body["removed"] == [] + + +@respx.mock +def test_users_update_noop_makes_no_call(logged_in, runner): + # adding a perm the member already has effectively → no-op: no PUT, exit 0. + respx.get(f"{BASE}/api/users").mock(return_value=httpx.Response(200, json=[ + _user(id="u1", email="a@b.com", permissions=["events:read"]), + ])) + put = respx.put(f"{BASE}/api/users/u1").mock(return_value=httpx.Response(200, json=_user())) + result = runner.invoke(app, ["--json", "users", "update", "a@b.com", "--add", "events:read", "--yes"]) + assert result.exit_code == 0, result.output + assert not put.called # no server call for a no-op + assert json.loads(result.stdout)["added"] == [] + + +def test_users_update_noop_flags_is_rejected(logged_in, runner): + # No flags → usage error, NOT a silent wipe (no routes mocked → must fail before any call). + result = runner.invoke(app, ["users", "update", "a@b.com", "--yes"]) + assert result.exit_code == 2 + + +def test_users_update_add_remove_same_perm_rejected(logged_in, runner): + result = runner.invoke(app, ["users", "update", "a@b.com", + "--add", "keys:create", "--remove", "keys:create", "--yes"]) + assert result.exit_code == 2 + + +@respx.mock +def test_users_update_not_found_exits_6(logged_in, runner): + respx.get(f"{BASE}/api/users").mock(return_value=httpx.Response(200, json=[_user(email="a@b.com")])) + result = runner.invoke(app, ["users", "update", "nope@b.com", "--add", "keys:create", "--yes"]) + assert result.exit_code == 6 + + +# --- users disable / enable ------------------------------------------------- + + +@respx.mock +def test_users_disable_then_json(logged_in, runner): + respx.get(f"{BASE}/api/users").mock(return_value=httpx.Response(200, json=[ + _user(id="u1", email="dev@example.com"), + ])) + route = respx.delete(f"{BASE}/api/users/u1").mock(return_value=httpx.Response(200, json={"disabled": True})) + result = runner.invoke(app, ["--json", "users", "disable", "dev@example.com", "--yes"]) + assert result.exit_code == 0, result.output + assert route.called + assert json.loads(result.stdout)["status"] == "disabled" + + +@respx.mock +def test_users_disable_protected_refused(logged_in, runner): + respx.get(f"{BASE}/api/users").mock(return_value=httpx.Response(200, json=[ + _user(id="u1", email="root@example.com", is_protected=True), + ])) + delete = respx.delete(f"{BASE}/api/users/u1").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["users", "disable", "root@example.com", "--yes"]) + assert result.exit_code == 5 + assert not delete.called # refused client-side, never hit the server + + +@respx.mock +def test_users_disable_self_refused(logged_in, runner): + # logged_in fixture seeds email="me@test" + respx.get(f"{BASE}/api/users").mock(return_value=httpx.Response(200, json=[ + _user(id="u1", email="me@test"), + ])) + delete = respx.delete(f"{BASE}/api/users/u1").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["users", "disable", "me@test", "--yes"]) + assert result.exit_code == 5 + assert not delete.called + + +@respx.mock +def test_users_disable_already_disabled_noop(logged_in, runner): + respx.get(f"{BASE}/api/users").mock(return_value=httpx.Response(200, json=[ + _user(id="u1", email="off@example.com", disabled_at="2026-01-01T00:00:00Z"), + ])) + delete = respx.delete(f"{BASE}/api/users/u1").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["--json", "users", "disable", "off@example.com", "--yes"]) + assert result.exit_code == 0, result.output + assert not delete.called # no-op + + +@respx.mock +def test_users_disable_not_found_exits_6(logged_in, runner): + respx.get(f"{BASE}/api/users").mock(return_value=httpx.Response(200, json=[_user(email="a@test")])) + result = runner.invoke(app, ["users", "disable", "nobody@test", "--yes"]) + assert result.exit_code == 6 + + +@respx.mock +def test_users_enable(logged_in, runner): + respx.get(f"{BASE}/api/users").mock(return_value=httpx.Response(200, json=[ + _user(id="u1", email="off@example.com", disabled_at="2026-01-01T00:00:00Z"), + ])) + respx.post(f"{BASE}/api/users/u1/enable").mock( + return_value=httpx.Response(200, json=_user(id="u1", email="off@example.com", disabled_at=None)) + ) + result = runner.invoke(app, ["--json", "users", "enable", "off@example.com", "--yes"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["status"] == "active" + + +@respx.mock +def test_users_enable_already_active_noop(logged_in, runner): + respx.get(f"{BASE}/api/users").mock(return_value=httpx.Response(200, json=[ + _user(id="u1", email="on@example.com", disabled_at=None), + ])) + enable = respx.post(f"{BASE}/api/users/u1/enable").mock(return_value=httpx.Response(200, json=_user())) + result = runner.invoke(app, ["--json", "users", "enable", "on@example.com", "--yes"]) + assert result.exit_code == 0, result.output + assert not enable.called # no-op + + +# --- settings --------------------------------------------------------------- + + +@respx.mock +def test_settings_list_json(logged_in, runner): + respx.get(f"{BASE}/api/settings").mock( + return_value=httpx.Response(200, json={"settings": [{"key": "session_ttl_secs", "value": 86400, "updated_at": "t", "updated_by": None}]}) + ) + result = runner.invoke(app, ["--json", "settings", "list"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["settings"][0]["key"] == "session_ttl_secs" + + +_SETTINGS = [ + {"key": "session_ttl_secs", "value": 86400, "updated_by": "admin@local.host", "updated_at": "2026-06-25T16:00:00Z", + "schema": {"kind": "positive_int", "label": "session lifetime", "min": 60, "max": 2592000, "unit": "seconds", + "description": "how long a dashboard login stays valid"}}, + {"key": "alerts.email_default_recipients", "value": ["admin@local.host"], "updated_at": "2026-06-28T06:00:00Z", + "schema": {"kind": "email_list", "description": "default alert email recipients"}}, + {"key": "alerts.webhook_signing_secret", "value": "", "updated_at": "2026-06-28T06:00:00Z", + "schema": {"kind": "secret", "description": "HMAC signing key"}}, +] + + +@respx.mock +def test_settings_list_human_boxed(logged_in, runner): + respx.get(f"{BASE}/api/settings").mock(return_value=httpx.Response(200, json={"settings": _SETTINGS})) + result = runner.invoke(app, ["settings", "list"]) + assert result.exit_code == 0, result.output + out = result.stdout + result.stderr + assert "settings · 3" in out + for c in ("key", "value", "type", "updated"): + assert c in out + assert "session_ttl_secs" in out and "86400" in out and "integer" in out + assert "(secret)" in out # secret value masked, never echoed + assert "change one with" not in out # the footer hint was removed + + +@respx.mock +def test_settings_schema_human(logged_in, runner): + respx.get(f"{BASE}/api/settings").mock(return_value=httpx.Response(200, json={"settings": _SETTINGS})) + result = runner.invoke(app, ["settings", "schema"]) + assert result.exit_code == 0, result.output + out = result.stdout + assert "settings schema · 3" in out + for c in ("key", "type", "accepts"): + assert c in out + assert "60–2592000 seconds" in out # positive_int range + unit (description wraps; see output test) + + +@respx.mock +def test_settings_schema_json_includes_kind(logged_in, runner): + respx.get(f"{BASE}/api/settings").mock(return_value=httpx.Response(200, json={"settings": _SETTINGS})) + result = runner.invoke(app, ["--json", "settings", "schema"]) + assert result.exit_code == 0, result.output + entries = {e["key"]: e for e in json.loads(result.stdout)["settings"]} + assert entries["session_ttl_secs"]["kind"] == "positive_int" + + +@respx.mock +def test_settings_set_scalar_int(logged_in, runner): + respx.get(f"{BASE}/api/settings").mock(return_value=httpx.Response(200, json={"settings": _SETTINGS})) + route = respx.put(f"{BASE}/api/settings/session_ttl_secs").mock( + return_value=httpx.Response(200, json={"key": "session_ttl_secs", "value": 3600}) + ) + result = runner.invoke(app, ["--json", "settings", "set", "session_ttl_secs", "--value", "3600", "--yes"]) + assert result.exit_code == 0, result.output + assert json.loads(route.calls.last.request.content) == {"value": 3600} + + +@respx.mock +def test_settings_set_json_value_array(logged_in, runner): + respx.get(f"{BASE}/api/settings").mock(return_value=httpx.Response(200, json={"settings": _SETTINGS})) + route = respx.put(f"{BASE}/api/settings/alerts.email_default_recipients").mock( + return_value=httpx.Response(200, json={"key": "alerts.email_default_recipients", "value": ["a@b.com"]}) + ) + result = runner.invoke( + app, ["--json", "settings", "set", "alerts.email_default_recipients", "--json-value", '["a@b.com"]', "--yes"] + ) + assert result.exit_code == 0, result.output + assert json.loads(route.calls.last.request.content) == {"value": ["a@b.com"]} + + +def test_settings_set_requires_one_value_source(logged_in, runner): + result = runner.invoke(app, ["settings", "set", "k", "--value", "1", "--json-value", "2", "--yes"]) + assert result.exit_code == 2 + + +@respx.mock +def test_settings_set_unknown_key_exits_6(logged_in, runner): + # Settings are a fixed registry → an unknown key is rejected before any PUT. + respx.get(f"{BASE}/api/settings").mock(return_value=httpx.Response(200, json={"settings": _SETTINGS})) + put = respx.put(f"{BASE}/api/settings/nope").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["settings", "set", "nope", "--value", "1", "--yes"]) + assert result.exit_code == 6 + assert not put.called + + +@respx.mock +def test_settings_set_noop_makes_no_put(logged_in, runner): + respx.get(f"{BASE}/api/settings").mock(return_value=httpx.Response(200, json={"settings": _SETTINGS})) + put = respx.put(f"{BASE}/api/settings/session_ttl_secs").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["settings", "set", "session_ttl_secs", "--value", "86400", "--yes"]) # same value + assert result.exit_code == 0, result.output + assert not put.called # no-op → no server call + + +@respx.mock +def test_settings_set_invalid_value_clean_error(logged_in, runner): + respx.get(f"{BASE}/api/settings").mock(return_value=httpx.Response(200, json={"settings": _SETTINGS})) + respx.put(f"{BASE}/api/settings/session_ttl_secs").mock( + return_value=httpx.Response(422, json={"error": "value must be between 60 and 2592000"})) + result = runner.invoke(app, ["settings", "set", "session_ttl_secs", "--value", "5", "--yes"]) + assert result.exit_code == 1 + assert "value must be between 60 and 2592000" in result.stderr + + +@respx.mock +def test_users_show_finds_a_member_whose_email_the_server_lowercased(logged_in, runner): + """`users create` is normalised server-side; the lookup commands were not. + + `fp users create Alice.Chen@Example.com` stores `alice.chen@example.com`, so every later + show/update/disable/enable on the exact string the caller had just typed answered + `no user with email "Alice.Chen@Example.com"` (exit 6) — the CLI denying a member it had + itself just created, reachable only via a lowercased form nothing told them about. + """ + respx.get(f"{BASE}/api/users").mock( + return_value=httpx.Response(200, json=[_user(id="u1", email="alice.chen@example.com")]) + ) + result = runner.invoke(app, ["--json", "users", "show", "Alice.Chen@Example.com"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["email"] == "alice.chen@example.com" diff --git a/fp-cli/tests/test_orgs.py b/fp-cli/tests/test_orgs.py new file mode 100644 index 000000000..df47c648e --- /dev/null +++ b/fp-cli/tests/test_orgs.py @@ -0,0 +1,635 @@ +"""Multi-tenancy: org selection at login, --org header injection, orgs/org commands.""" + +from __future__ import annotations + +import json + +import httpx +import respx + +from fp_cli import config +from fp_cli.app import app + +BASE = "http://dash.test" + + +def _session(memberships, *, is_admin=False, id="u1", email="me@test"): + return {"id": id, "email": email, "is_instance_admin": is_admin, "memberships": memberships} + + +_ACME = {"org_id": "o1", "org_slug": "acme", "org_name": "Acme", "permissions": ["events:read"], "permission_set": "standard"} +_GLOBEX = {"org_id": "o2", "org_slug": "globex", "org_name": "Globex", "permissions": ["events:read", "keys:create"], "permission_set": "admin"} + + +def _otp_routes(user): + respx.post(f"{BASE}/api/auth/otp/request").mock(return_value=httpx.Response(200, json={"ok": True})) + # The OTP-verify payload is intentionally slim (no memberships); login reads the + # authoritative memberships from GET /api/auth/session, so mock that too. + respx.post(f"{BASE}/api/auth/otp/verify").mock( + return_value=httpx.Response( + 200, + json={"user": {"id": user["id"], "email": user["email"]}, "expires_in_secs": 3600}, + headers={"set-cookie": "ae_session=tok; Path=/"}, + ) + ) + respx.get(f"{BASE}/api/auth/session").mock(return_value=httpx.Response(200, json=user)) + + +# --- login org selection ---------------------------------------------------- + + +@respx.mock +def test_login_single_org_auto_selects(home, runner): + _otp_routes(_session([_ACME])) + result = runner.invoke(app, ["--base-url", BASE, "login", "--email", "me@test"], input="123456\n") + assert result.exit_code == 0, result.output + assert config.load_config().org == "acme" + + +@respx.mock +def test_login_org_flag_persists(home, runner): + _otp_routes(_session([_ACME, _GLOBEX])) + result = runner.invoke( + app, ["--base-url", BASE, "login", "--email", "me@test", "--org", "globex"], input="123456\n" + ) + assert result.exit_code == 0, result.output + assert config.load_config().org == "globex" + + +@respx.mock +def test_login_org_not_a_member_is_usage_error(home, runner): + _otp_routes(_session([_ACME])) + result = runner.invoke( + app, ["--base-url", BASE, "login", "--email", "me@test", "--org", "globex"], input="123456\n" + ) + assert result.exit_code == 2 # BadParameter: not a member of globex + + +@respx.mock +def test_login_multi_org_no_selection_exits_nonzero_json(home, runner): + # Non-interactive (CliRunner) multi-org login without --org: must not block. + _otp_routes(_session([_ACME, _GLOBEX])) + result = runner.invoke(app, ["--base-url", BASE, "--json", "login", "--email", "me@test"], input="123456\n") + assert result.exit_code == 2, result.output + # CliRunner echoes the typed OTP back onto stdout (its fake `input()` does what a terminal + # normally does), so stdout here is "<echo>\n<envelope>". That prefix is a harness artifact, + # not part of the --json contract — a real run echoes nothing — so parse from the envelope's + # opening brace and keep asserting that the envelope itself lands on stdout. + payload = json.loads(result.stdout[result.stdout.index("{") :]) + assert payload["needs_org_selection"] is True + assert set(payload["orgs"]) == {"acme", "globex"} + # token saved so the user can `orgs switch` without re-doing OTP + assert config.load_config().session_token == "tok" + + +# --- interactive org picker (multi-tenant) ---------------------------------- +# +# The CliRunner's stdin is not a TTY, so the picker is gated behind +# `_stdin_is_tty()` which we stub True. The OTP code is the first stdin line; +# the picker choice (slug / number / Enter-for-default) is the next. + + +def _tty(monkeypatch): + monkeypatch.setattr("fp_cli.select.stdin_is_tty", lambda: True) + + +@respx.mock +def test_login_multi_org_picker_by_slug(home, runner, monkeypatch): + _otp_routes(_session([_ACME, _GLOBEX])) + _tty(monkeypatch) + result = runner.invoke( + app, ["--base-url", BASE, "login", "--email", "me@test"], input="123456\nglobex\n" + ) + assert result.exit_code == 0, result.output + assert config.load_config().org == "globex" + + +@respx.mock +def test_login_multi_org_picker_by_number(home, runner, monkeypatch): + # memberships order is acme, globex → "2" selects globex + _otp_routes(_session([_ACME, _GLOBEX])) + _tty(monkeypatch) + result = runner.invoke( + app, ["--base-url", BASE, "login", "--email", "me@test"], input="123456\n2\n" + ) + assert result.exit_code == 0, result.output + assert config.load_config().org == "globex" + + +@respx.mock +def test_login_multi_org_picker_rejects_invalid_then_accepts(home, runner, monkeypatch): + # A non-member / garbage choice must NOT be accepted — it re-prompts (no leak). + _otp_routes(_session([_ACME, _GLOBEX])) + _tty(monkeypatch) + result = runner.invoke( + app, ["--base-url", BASE, "login", "--email", "me@test"], input="123456\nnope\n9\nacme\n" + ) + assert result.exit_code == 0, result.output + assert config.load_config().org == "acme" + assert "not one of your orgs" in (result.stderr or "") + + +@respx.mock +def test_login_saved_org_does_not_bypass_picker(home, runner, monkeypatch): + # THE FIX: a previously-saved tenant must not silently re-activate — the picker + # still runs and a different org can be chosen. + config.save_config(config.CliConfig(base_url=BASE, org="acme")) + _otp_routes(_session([_ACME, _GLOBEX])) + _tty(monkeypatch) + result = runner.invoke( + app, ["--base-url", BASE, "login", "--email", "me@test"], input="123456\nglobex\n" + ) + assert result.exit_code == 0, result.output + assert config.load_config().org == "globex" # chose globex despite saved acme + + +@respx.mock +def test_login_saved_org_is_picker_default_on_enter(home, runner, monkeypatch): + # The saved tenant is offered as the Enter-to-keep default. + config.save_config(config.CliConfig(base_url=BASE, org="acme")) + _otp_routes(_session([_ACME, _GLOBEX])) + _tty(monkeypatch) + result = runner.invoke( + app, ["--base-url", BASE, "login", "--email", "me@test"], input="123456\n\n" + ) + assert result.exit_code == 0, result.output + assert config.load_config().org == "acme" + + +@respx.mock +def test_login_stale_saved_org_not_offered_as_default(home, runner, monkeypatch): + # A saved org the user is no longer a member of must not be the default; a bare + # Enter then has no default → re-prompt (we follow with a valid pick). + config.save_config(config.CliConfig(base_url=BASE, org="zombie")) + _otp_routes(_session([_ACME, _GLOBEX])) + _tty(monkeypatch) + result = runner.invoke( + app, ["--base-url", BASE, "login", "--email", "me@test"], input="123456\nacme\n" + ) + assert result.exit_code == 0, result.output + assert config.load_config().org == "acme" + + +@respx.mock +def test_login_global_org_flag_skips_picker(home, runner, monkeypatch): + # An explicit global --org (before the command) is a deliberate choice → no picker. + _otp_routes(_session([_ACME, _GLOBEX])) + _tty(monkeypatch) + result = runner.invoke( + app, ["--base-url", BASE, "--org", "globex", "login", "--email", "me@test"], input="123456\n" + ) + assert result.exit_code == 0, result.output + assert config.load_config().org == "globex" + + +@respx.mock +def test_login_env_org_skips_picker(home, runner, monkeypatch): + # FP_ORG is an explicit choice → no picker. + monkeypatch.setenv("FP_ORG", "globex") + _otp_routes(_session([_ACME, _GLOBEX])) + _tty(monkeypatch) + result = runner.invoke( + app, ["--base-url", BASE, "login", "--email", "me@test"], input="123456\n" + ) + assert result.exit_code == 0, result.output + assert config.load_config().org == "globex" + + +@respx.mock +def test_login_admin_can_select_existing_nonmember_org_via_flag(home, runner): + # An instance admin may activate a non-member org via --org ONLY if it exists + # and is accessible — the server probe (/api/access-granters) returns 200. + _otp_routes(_session([_ACME], is_admin=True)) + respx.get(f"{BASE}/api/access-granters").mock( + return_value=httpx.Response(200, json=["dev"]) + ) + result = runner.invoke( + app, ["--base-url", BASE, "login", "--email", "me@test", "--org", "globex"], input="123456\n" + ) + assert result.exit_code == 0, result.output + assert config.load_config().org == "globex" + + +@respx.mock +def test_login_admin_nonexistent_org_rejected(home, runner): + # THE BUG FIX: an instance admin requesting a NON-EXISTENT org must be rejected, + # not silently accepted+saved. The probe returns 403 (no such org / no access). + _otp_routes(_session([_ACME], is_admin=True)) + respx.get(f"{BASE}/api/access-granters").mock( + return_value=httpx.Response(403, json={}) + ) + result = runner.invoke( + app, ["--base-url", BASE, "login", "--email", "me@test", "--org", "fp"], input="123456\n" + ) + assert result.exit_code == 2, result.output + assert config.load_config().org is None # nothing bogus persisted + + +@respx.mock +def test_login_env_nonexistent_org_rejected(home, runner, monkeypatch): + # FP_ORG follows the SAME validation as --org (both feed `requested`). + monkeypatch.setenv("FP_ORG", "fp") + _otp_routes(_session([_ACME], is_admin=True)) + respx.get(f"{BASE}/api/access-granters").mock( + return_value=httpx.Response(403, json={}) + ) + result = runner.invoke(app, ["--base-url", BASE, "login", "--email", "me@test"], input="123456\n") + assert result.exit_code == 2, result.output + assert config.load_config().org is None + + +@respx.mock +def test_login_nonadmin_nonmember_rejected_without_probe(home, runner): + # A regular user's non-member org is rejected by the membership check alone — + # no server probe is made (the membership list is conclusive for non-admins). + _otp_routes(_session([_ACME], is_admin=False)) + probe = respx.get(f"{BASE}/api/access-granters").mock( + return_value=httpx.Response(200, json=["dev"]) + ) + result = runner.invoke( + app, ["--base-url", BASE, "login", "--email", "me@test", "--org", "globex"], input="123456\n" + ) + assert result.exit_code == 2, result.output + assert not probe.called + + +@respx.mock +def test_login_multi_org_noninteractive_reuses_saved(home, runner): + # Non-interactive (CliRunner stdin is not a TTY), no --json: a still-valid saved + # tenant is reused without prompting (back-compat for piped re-login). + config.save_config(config.CliConfig(base_url=BASE, org="acme")) + _otp_routes(_session([_ACME, _GLOBEX])) + result = runner.invoke( + app, ["--base-url", BASE, "login", "--email", "me@test"], input="123456\n" + ) + assert result.exit_code == 0, result.output + assert config.load_config().org == "acme" + + +@respx.mock +def test_login_multi_org_noninteractive_stale_saved_needs_selection(home, runner): + # Non-interactive with a saved org the user is NO LONGER a member of → must not + # silently reuse it; falls through to needs-selection (exit 2). + config.save_config(config.CliConfig(base_url=BASE, org="zombie")) + _otp_routes(_session([_ACME, _GLOBEX])) + result = runner.invoke( + app, ["--base-url", BASE, "login", "--email", "me@test"], input="123456\n" + ) + assert result.exit_code == 2, result.output + assert config.load_config().org is None + + +@respx.mock +def test_login_noninteractive_multi_org_no_saved_needs_selection_plain(home, runner): + # Non-interactive, NON-json, multi-org, nothing saved → needs selection, token kept. + _otp_routes(_session([_ACME, _GLOBEX])) + result = runner.invoke( + app, ["--base-url", BASE, "login", "--email", "me@test"], input="123456\n" + ) + assert result.exit_code == 2, result.output + assert config.load_config().session_token == "tok" + assert config.load_config().org is None + + +# --- X-AgentEye-Org header injection ---------------------------------------- + + +@respx.mock +def test_org_flag_sets_header(logged_in, runner): + route = respx.get(f"{BASE}/api/sessions").mock( + return_value=httpx.Response(200, json={"sessions": [], "next_cursor": None}) + ) + result = runner.invoke(app, ["--org", "acme", "sessions"]) + assert result.exit_code == 0, result.output + assert route.calls.last.request.headers.get("X-AgentEye-Org") == "acme" + + +@respx.mock +def test_saved_org_sets_header(home, runner): + config.save_config( + config.CliConfig(base_url=BASE, session_token="tok", expires_at="2999-01-01T00:00:00Z", org="globex") + ) + route = respx.get(f"{BASE}/api/sessions").mock( + return_value=httpx.Response(200, json={"sessions": [], "next_cursor": None}) + ) + result = runner.invoke(app, ["sessions"]) + assert result.exit_code == 0, result.output + assert route.calls.last.request.headers.get("X-AgentEye-Org") == "globex" + + +def test_bad_org_slug_is_usage_error(logged_in, runner): + result = runner.invoke(app, ["--org", "Bad_Slug!", "sessions"]) + assert result.exit_code == 2 + + +# --- whoami multi-tenant shape ---------------------------------------------- + + +@respx.mock +def test_whoami_shows_memberships_and_active_org(logged_in, runner): + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([_ACME, _GLOBEX], is_admin=True)) + ) + result = runner.invoke(app, ["--org", "globex", "--json", "whoami"]) + assert result.exit_code == 0, result.output + data = json.loads(result.stdout) + assert data["active_org"] == "globex" + assert data["is_instance_admin"] is True + assert data["permissions"] == ["events:read", "keys:create"] # globex's grants + assert {m["org_slug"] for m in data["memberships"]} == {"acme", "globex"} + + +# --- orgs list / orgs current ----------------------------------------------- +# (org selection/persistence/validation is covered by the orgs switch tests below; +# `orgs use` was removed in favour of `orgs switch`.) + + +@respx.mock +def test_orgs_list_json(logged_in, runner): + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([_ACME, _GLOBEX])) + ) + result = runner.invoke(app, ["--org", "acme", "--json", "orgs", "list"]) + assert result.exit_code == 0, result.output + data = json.loads(result.stdout) + assert data["active_org"] == "acme" + assert {o["org_slug"] for o in data["orgs"]} == {"acme", "globex"} + + +@respx.mock +def test_orgs_current_json(logged_in, runner): + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([_ACME, _GLOBEX])) + ) + result = runner.invoke(app, ["--org", "globex", "--json", "orgs", "current"]) + assert result.exit_code == 0, result.output + data = json.loads(result.stdout) + assert data["slug"] == "globex" + assert data["name"] == "Globex" and data["role"] == "admin" + assert data["permission_count"] == 2 # globex's two grants + assert data["user_email"] # reports who you're signed in as + + +@respx.mock +def test_orgs_perms_json(logged_in, runner): + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([_ACME, _GLOBEX])) + ) + result = runner.invoke(app, ["--org", "globex", "--json", "orgs", "perms"]) + assert result.exit_code == 0, result.output + data = json.loads(result.stdout) + assert data["slug"] == "globex" and data["role"] == "admin" + assert data["permissions"] == ["events:read", "keys:create"] # globex's flat grants + assert data["permission_count"] == 2 + + +@respx.mock +def test_orgs_perms_table(logged_in, runner): + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([_ACME, _GLOBEX])) + ) + result = runner.invoke(app, ["--org", "globex", "orgs", "perms"]) + assert result.exit_code == 0, result.output + # identity header (name · slug · role) + the shared grouped permissions panel, with the + # org NAME in the panel's border title. + assert "Globex" in result.stdout and "globex" in result.stdout and "role admin" in result.stdout + assert "permissions · 2 · Globex" in result.stdout + assert "events" in result.stdout and "keys" in result.stdout + + +@respx.mock +def test_org_use_admin_nonexistent_org_rejected(logged_in, runner): + # Instance admin → a NON-EXISTENT org (probe 403) is rejected, not persisted. + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([_ACME], is_admin=True)) + ) + respx.get(f"{BASE}/api/access-granters").mock( + return_value=httpx.Response(403, json={}) + ) + result = runner.invoke(app, ["orgs", "use", "fp"]) + assert result.exit_code == 2 + assert config.load_config().org is None + + +@respx.mock +def test_logout_clears_active_org_and_identity(logged_in, runner): + # Logout must not leave a remembered org/identity in cli.json — only base_url + # (and prefs) survive, so the next login starts fresh. + cfg = config.load_config() + cfg.org = "acme" + config.save_config(cfg) + respx.post(f"{BASE}/api/auth/logout").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["logout"]) + assert result.exit_code == 0, result.output + after = config.load_config() + assert after.session_token is None + assert after.org is None + assert after.email is None + assert after.user_id is None + assert after.base_url == BASE # kept so the next login needs no --base-url + + +def test_logout_when_not_signed_in_is_noop(home, runner): + # No session → logout must NOT claim a sign-out happened (no server call either). + result = runner.invoke(app, ["--base-url", BASE, "logout"]) + assert result.exit_code == 0, result.output + assert "already signed out" in (result.stderr or "").lower() + + +def test_logout_when_not_signed_in_json(home, runner): + result = runner.invoke(app, ["--base-url", BASE, "--json", "logout"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + assert payload["already_signed_out"] is True + + +# --- org list (and the orgs-list alias) ------------------------------------- + + +@respx.mock +def test_org_list_json_shows_role_and_active(logged_in, runner): + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([_ACME, _GLOBEX], is_admin=True)) + ) + result = runner.invoke(app, ["--org", "globex", "--json", "orgs", "list"]) + assert result.exit_code == 0, result.output + data = json.loads(result.stdout) + assert data["active_org"] == "globex" + assert data["is_instance_admin"] is True + byslug = {o["org_slug"]: o for o in data["orgs"]} + assert byslug["globex"]["active"] is True and byslug["acme"]["active"] is False + assert byslug["acme"]["permission_set"] == "standard" # your role in that org + + +@respx.mock +def test_org_list_table_lists_all_orgs(logged_in, runner): + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([_ACME, _GLOBEX])) + ) + result = runner.invoke(app, ["--org", "acme", "orgs", "list"]) + assert result.exit_code == 0, result.output + assert "acme" in result.stdout and "globex" in result.stdout + + +@respx.mock +def test_orgs_list_alias_still_works(logged_in, runner): + # `orgs list` lists your orgs (merged single `orgs` group). + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([_ACME])) + ) + result = runner.invoke(app, ["--org", "acme", "--json", "orgs", "list"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["active_org"] == "acme" + + +# --- org switch ------------------------------------------------------------- + + +@respx.mock +def test_org_switch_to_member(logged_in, runner): + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([_ACME, _GLOBEX])) + ) + result = runner.invoke(app, ["orgs", "switch", "globex"]) + assert result.exit_code == 0, result.output + assert config.load_config().org == "globex" + + +@respx.mock +def test_org_switch_non_member_rejected_without_probe(logged_in, runner): + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([_ACME])) + ) + probe = respx.get(f"{BASE}/api/access-granters").mock( + return_value=httpx.Response(200, json=["dev"]) + ) + result = runner.invoke(app, ["orgs", "switch", "globex"]) + assert result.exit_code == 2 + assert not probe.called # non-admin → membership check is conclusive + assert config.load_config().org is None + + +@respx.mock +def test_org_switch_admin_existing_nonmember_allowed(logged_in, runner): + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([_ACME], is_admin=True)) + ) + respx.get(f"{BASE}/api/access-granters").mock( + return_value=httpx.Response(200, json=["dev"]) + ) + result = runner.invoke(app, ["orgs", "switch", "globex"]) + assert result.exit_code == 0, result.output + assert config.load_config().org == "globex" + + +@respx.mock +def test_org_switch_admin_nonexistent_rejected(logged_in, runner): + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([_ACME], is_admin=True)) + ) + respx.get(f"{BASE}/api/access-granters").mock( + return_value=httpx.Response(403, json={}) + ) + result = runner.invoke(app, ["orgs", "switch", "fp"]) + assert result.exit_code == 2 + assert config.load_config().org is None + + +@respx.mock +def test_org_switch_no_arg_single_org_auto_selects(logged_in, runner): + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([_ACME])) + ) + result = runner.invoke(app, ["orgs", "switch"]) + assert result.exit_code == 0, result.output + assert config.load_config().org == "acme" + + +@respx.mock +def test_org_switch_no_arg_interactive_picker(logged_in, runner, monkeypatch): + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([_ACME, _GLOBEX])) + ) + _tty(monkeypatch) + result = runner.invoke(app, ["orgs", "switch"], input="globex\n") + assert result.exit_code == 0, result.output + assert config.load_config().org == "globex" + + +@respx.mock +def test_org_switch_no_arg_noninteractive_requires_slug(logged_in, runner): + # CliRunner stdin is not a TTY and no slug given → must not hang; error out. + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([_ACME, _GLOBEX])) + ) + result = runner.invoke(app, ["orgs", "switch"]) + assert result.exit_code == 2 + assert config.load_config().org is None + + +@respx.mock +def test_org_switch_to_different_renders_switched_card(logged_in, runner): + # Positional switch to a DIFFERENT org → the boxed 'switched org' card (was <prev>). + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([_ACME, _GLOBEX])) + ) + result = runner.invoke(app, ["--org", "acme", "orgs", "switch", "globex"]) + assert result.exit_code == 0, result.output + assert config.load_config().org == "globex" + assert "switched org" in result.stderr and "globex" in result.stderr and "was acme" in result.stderr + + +@respx.mock +def test_org_switch_to_current_is_noop(logged_in, runner): + # Positional switch to the org you're already on → calm 'already on' no-op, not the card. + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([_ACME, _GLOBEX])) + ) + result = runner.invoke(app, ["--org", "acme", "orgs", "switch", "acme"]) + assert result.exit_code == 0, result.output + assert "already on acme" in result.stderr + assert "switched org" not in result.stderr + + +@respx.mock +def test_org_switch_not_found_did_you_mean(logged_in, runner): + # A near-miss slug → styled not-found + did-you-mean + hint, non-zero exit. + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([_ACME, _GLOBEX])) + ) + result = runner.invoke(app, ["orgs", "switch", "acm"]) + assert result.exit_code == 2 + assert "no org named acm" in result.stderr + assert "did you mean acme" in result.stderr + assert config.load_config().org is None + + +@respx.mock +def test_org_switch_single_org_says_only_org(logged_in, runner): + # One org → no picker; calm 'only org' line; still persisted as active. + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([_ACME])) + ) + result = runner.invoke(app, ["orgs", "switch"]) + assert result.exit_code == 0, result.output + assert "only org" in result.stderr and "acme" in result.stderr + assert config.load_config().org == "acme" + + +@respx.mock +def test_login_already_signed_in_switches_org_via_flag(home, runner): + # A valid session is active on org acme; `login --org globex` must honor the explicit + # selector and switch the active tenant (no re-auth), not drop it and report "already + # signed in" on the old org (the bug). + config.save_config(config.CliConfig( + base_url=BASE, session_token="tok", expires_at="2999-01-01T00:00:00Z", + email="me@test", user_id="u1", org="acme", + )) + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([_ACME, _GLOBEX])) + ) + result = runner.invoke(app, ["--json", "login", "--org", "globex"]) + assert result.exit_code == 0, result.output + data = json.loads(result.stdout) + assert data["org"] == "globex" and data.get("switched_org") is True + assert config.load_config().org == "globex" diff --git a/fp-cli/tests/test_output.py b/fp-cli/tests/test_output.py new file mode 100644 index 000000000..434b5cbb8 --- /dev/null +++ b/fp-cli/tests/test_output.py @@ -0,0 +1,1637 @@ +from __future__ import annotations + +import json + +from rich.console import Console + +from fp_cli import output, theme +from fp_cli.models import (Alert, AgentEvent, ApiKey, DashboardUser, Evaluation, Incident, + IncidentComment, IncidentSubscriber, QueryResult, SavedQuery, Session, + SessionUser) + + +def _event(ts: str, event_type: str = "tool_use") -> AgentEvent: + return AgentEvent(id=1, session_id="sess-1", agent_id="bot", + event_type=event_type, ts=ts, environment="prod") + + +def _wide_stdout(width: int = 140) -> None: + """Force wide, no-color stdout+stderr consoles so panels/footers never wrap in assertions.""" + output.configure(no_color=True, quiet=False) + output._stdout = Console(width=width, no_color=True) + output._stderr = Console(stderr=True, width=width, no_color=True) + + +def test_format_scores(): + assert output.format_scores(None) == "-" + assert output.format_scores({}) == "-" + rendered = output.format_scores({"helpfulness": 0.857, "count": 1}) + assert "helpfulness=0.86" in rendered + assert "count=1.00" in rendered + + +def test_emit_json_roundtrip(capsys): + output.emit_json({"a": 1, "b": [1, 2, 3]}) + out = capsys.readouterr().out + assert json.loads(out) == {"a": 1, "b": [1, 2, 3]} + + +def test_emit_json_serializes_dataclasses(capsys): + output.emit_json({"user": SessionUser(id="u1", email="e@test")}) + data = json.loads(capsys.readouterr().out) + assert data["user"] == { + "id": "u1", + "email": "e@test", + "is_instance_admin": False, + "memberships": [], + } + + +def test_print_table_writes_to_stdout(capsys): + output.configure(no_color=True, quiet=False) + output.print_table(["a", "b"], [["1", "2"]], title="Demo") + out = capsys.readouterr().out + assert "Demo" in out + assert "1" in out and "2" in out + + +def test_info_and_error_go_to_stderr(capsys): + output.configure(no_color=True, quiet=False) + output.info("hello") + output.error("boom") + captured = capsys.readouterr() + assert "hello" in captured.err + assert "boom" in captured.err + assert captured.out == "" + + +def test_quiet_suppresses_info(capsys): + output.configure(no_color=True, quiet=True) + output.info("hidden") + output.error("shown") + captured = capsys.readouterr() + assert "hidden" not in captured.err + assert "shown" in captured.err + # reset for other tests + output.configure(no_color=True, quiet=False) + + +# --- events box renderer ---------------------------------------------------- + + +def test_parse_iso(): + assert output._parse_iso("2026-06-22T12:21:54.104714Z") is not None + assert output._parse_iso("2026-06-22T12:21:54+00:00") is not None + assert output._parse_iso("t") is None # opaque/unparseable → None (caller falls back) + assert output._parse_iso("") is None + + +def test_render_events_box_to_stdout(capsys): + output.configure(no_color=True, quiet=False) + output.render_events([_event("2026-06-22T12:21:54Z")], order="desc") + captured = capsys.readouterr() + # The box (human data view) goes to STDOUT; the title carries count, direction and date. + assert "events" in captured.out + assert "newest first" in captured.out + assert "2026-06-22" in captured.out + assert captured.err == "" + + +def test_render_events_order_and_empty(capsys): + output.configure(no_color=True, quiet=False) + output.render_events([], order="asc") + out = capsys.readouterr().out + assert "oldest first" in out + assert "no events in this window" in out + + +def test_events_footer_more_available(capsys): + output.configure(no_color=True, quiet=False) + output.events_footer(10, more=True) + err = capsys.readouterr().err # the footer hint is stderr chrome + assert "10 shown" in err + assert "more available" in err + assert "fp events --all" in err + + +def test_events_footer_no_more_and_quiet(capsys): + output.configure(no_color=True, quiet=False) + output.events_footer(3, more=False) + err = capsys.readouterr().err + assert "3 shown" in err + assert "more available" not in err + output.configure(no_color=True, quiet=True) + output.events_footer(3, more=True) + assert capsys.readouterr().err == "" # suppressed under --quiet + output.configure(no_color=True, quiet=False) + + +def test_events_columns_reordered(capsys): + # New column order: time, type, env, agent, session. + _wide_stdout() + output.render_events([_event("2026-06-22T12:21:54Z", "tool_use")], order="desc") + out = capsys.readouterr().out + i = {c: out.index(c) for c in ("time", "type", "env", "agent", "session")} + assert i["time"] < i["type"] < i["env"] < i["agent"] < i["session"] + output.configure(no_color=True, quiet=False) + + +# --- sessions box renderer + score helpers ---------------------------------- + + +def test_fmt_score_num(): + assert output._fmt_score_num(0.94) == ".94" + assert output._fmt_score_num(0.90) == ".90" + assert output._fmt_score_num(1.0) == "1.0" + assert output._fmt_score_num(0.0) == ".00" + assert output._fmt_score_num(0.7) == ".70" + + +def test_fmt_avg(): + # aggregate avg keeps the leading zero; 1.00 -> 1.0 + assert output._fmt_avg(0.71) == "0.71" + assert output._fmt_avg(0.99) == "0.99" + assert output._fmt_avg(1.0) == "1.0" + + +def test_score_value_thresholds(): + output.configure(no_color=False, quiet=False) # no '!' marker in the colour path + assert output._score_value(0.94) == (".94", theme.SCORE_HIGH) # >= .80 cyan-green + assert output._score_value(0.66) == (".66", theme.AMBER) # .50–.80 amber + assert output._score_value(0.45) == (".45", theme.ERROR) # < .50 red + # non-numeric: pass/fail substring rule + assert output._score_value("fail") == ("fail", theme.ERROR) + assert output._score_value("pass") == ("pass", theme.SUCCESS) + assert output._score_value("partial")[1] == theme.TEXT_DIM + + +def test_score_value_no_color_failure_marker(): + output.configure(no_color=True, quiet=False) + label, _ = output._score_value(0.45) + assert label == ".45!" # < .50 gets a '!' so failures stay visible without colour + label_ok, _ = output._score_value(0.66) + assert label_ok == ".66" # >= .50 gets no marker + output.configure(no_color=False, quiet=False) + + +def test_status_color_enum(): + assert output._status_cell("done").style == theme.SUCCESS + assert output._status_cell("error").style == theme.ERROR + assert output._status_cell("timeout").style == theme.ERROR + assert output._status_cell("weird-unknown").style == theme.TEXT_DIM # never crash + + +def test_short_session(): + assert output._short_session("sess-20260615-fcf97e01") == "sess-…fcf97e01" + assert output._short_session("sess-20260615-fcf97e01", full=True) == "sess-20260615-fcf97e01" + assert output._short_session("nonnum-3-b9d9de") == "nonnum-3-b9d9de" # short → intact + assert output._short_session("short") == "short" + + +def test_scores_cell_budget_truncates_with_plus_n(): + scores = {"a": 0.9, "b": 0.8, "c": 0.7, "d": 0.6, "e": 0.5} + cell = output._scores_cell(scores, budget=24) + assert "+" in cell.plain # leftover pairs collapsed to +N + assert cell.cell_len <= 24 + 1 # stays within (roughly) the budget + full = output._scores_cell(scores, full=True) + assert "+" not in full.plain # full shows every pair + assert "a" in full.plain and "e" in full.plain + + +def _sample_eval(): + return Evaluation(id="1", session_id="sess-20260615-fcf97e01", agent_id="agent-codegen", + environment="staging", status="done", scores={"coherence": 0.94}, + completed_at="2026-06-22T12:56:31Z") + + +def test_render_sessions_box_has_no_scores(capsys): + _wide_stdout() + output.render_sessions([_sample_eval()]) + out = capsys.readouterr().out + assert "sessions" in out and "newest first" in out and "2026-06-22" in out + assert "agent-codegen" in out and "done" in out + assert "12:56:31" in out and "sess-…fcf97e01" in out + assert "coherence" not in out and "scores" not in out # scores moved to evals + output.configure(no_color=True, quiet=False) + + +def test_render_evals_box_has_scores(capsys): + _wide_stdout() + output.render_evals([_sample_eval()]) + out = capsys.readouterr().out + assert "evals" in out and "scores" in out and "coherence" in out + assert "agent-codegen" in out and "sess-…fcf97e01" in out + output.configure(no_color=True, quiet=False) + + +def test_render_sessions_empty(capsys): + _wide_stdout() + output.render_sessions([]) + assert "no sessions" in capsys.readouterr().out + output.configure(no_color=True, quiet=False) + + +def test_sessions_footer_plain_no_legend(capsys): + output.configure(no_color=True, quiet=False) + output.sessions_footer(3, more=True) + err = capsys.readouterr().err + assert "3 shown" in err and "fp sessions --all" in err + + +# ── multi-agent roster (agents column) ───────────────────────────────────── + +def _multi_session(): + """A 3-agent session: root `agent-codegen`, roster sorted by event count desc.""" + return Session( + session_id="sess-20260716-abcd1234", agent_id="agent-codegen", environment="dev", + last_event_at="2026-07-16T14:00:00Z", + agents=[ + {"agent_id": "agent-codegen", "event_count": 52}, + {"agent_id": "agent-linter", "event_count": 18}, + {"agent_id": "agent-testgen", "event_count": 9}, + ], + ) + + +def _single_session(): + return Session( + session_id="sess-20260716-single01", agent_id="solo-agent", environment="dev", + last_event_at="2026-07-16T13:00:00Z", + agents=[{"agent_id": "solo-agent", "event_count": 12}], + ) + + +def test_is_multi_agent(): + assert output.is_multi_agent(_multi_session()) is True + assert output.is_multi_agent(_single_session()) is False + # a legacy row with no `agents` field is never multi-agent (back-compat) + assert output.is_multi_agent(Session(session_id="s", agent_id="a", environment="dev")) is False + + +def test_render_sessions_shows_plus_n_badge(capsys): + _wide_stdout() + output.render_sessions([_multi_session()]) + out = capsys.readouterr().out + assert "agent-codegen +2" in out # 3 agents → +2 others, badge next to the root + # the other agents' NAMES are never listed inline in the default view + assert "agent-linter" not in out and "agent-testgen" not in out + output.configure(no_color=True, quiet=False) + + +def test_render_sessions_single_agent_has_no_badge(capsys): + _wide_stdout() + output.render_sessions([_single_session()]) + out = capsys.readouterr().out + assert "solo-agent" in out + assert "+" not in out # single-agent → no badge at all + output.configure(no_color=True, quiet=False) + + +def test_render_sessions_expanded_lists_full_roster(capsys): + _wide_stdout() + output.render_sessions_expanded([_multi_session()]) + out = capsys.readouterr().out + assert "agent-codegen" in out and "52 ev" in out # every agent listed with its count + assert "agent-linter" in out and "18 ev" in out + assert "agent-testgen" in out and "9 ev" in out + assert "●" not in out # uniform: no special "root" marker + assert "├" in out and "└" in out # uniform tree glyphs, └ closes the list + assert "sessions · 1" in out # panel count = sessions, not rows + output.configure(no_color=True, quiet=False) + + +def test_render_sessions_expanded_skips_single_agent(capsys): + _wide_stdout() + output.render_sessions_expanded([_single_session()]) + out = capsys.readouterr().out + assert "solo-agent" in out + assert "├ solo-agent" not in out # single-agent rows are not expanded + output.configure(no_color=True, quiet=False) + + +def test_sessions_footer_multi_agent(capsys): + output.configure(no_color=True, quiet=False) + output.sessions_footer(6, more=True, multi_agent=2) + err = capsys.readouterr().err + assert "6 shown" in err + assert "2 multi-agent" in err and "fp sessions --agents" in err + assert "fp sessions --all" in err # the more-available segment still shows + + +def test_sessions_footer_no_multi_agent_hides_segment(capsys): + output.configure(no_color=True, quiet=False) + output.sessions_footer(3, more=False, multi_agent=0) + err = capsys.readouterr().err + assert "3 shown" in err + assert "multi-agent" not in err and "--agents" not in err + assert "score:" not in err # sessions has no scores → no legend + output.configure(no_color=True, quiet=False) + + +def test_evals_footer_has_legend(capsys): + output.configure(no_color=True, quiet=False) + output.evals_footer(3, more=False) + err = capsys.readouterr().err + assert "3 shown" in err + assert "score:" in err and "≥.80" in err and "<.50" in err + output.configure(no_color=True, quiet=False) + + +def test_avg_bar_zoomed_braille(): + output.configure(no_color=False, quiet=False) # colour path → braille glyphs + # zoomed .40–1.0: 0.70 → (0.30/0.60)=0.5 → 5 filled + bar = output._avg_bar(0.70) + assert bar.plain.count("⣿") == 5 and bar.plain.count("⣀") == 5 + assert output._avg_bar(1.0).plain.count("⣿") == 10 # at/above hi → full + assert output._avg_bar(0.40).plain.count("⣿") == 0 # at floor → empty + assert output._avg_bar(None).plain == "⣀" * 10 # no avg → all track + + +def test_avg_bar_blocks_when_no_color(): + output.configure(no_color=True, quiet=False) # no-colour → solid-block fallback + bar = output._avg_bar(0.70) + assert bar.plain.count("█") == 5 and bar.plain.count("░") == 5 + output.configure(no_color=False, quiet=False) + + +def test_render_eval_aggregate(capsys): + _wide_stdout() + data = { + "total": 324, + "status_counts": {"done": 320, "error": 4, "timeout": 0}, + "score_stats": [ + {"key": "helpfulness", "count": 285, "avg": 0.66, "min": 0.28, "max": 1.0, "p50": 0.7}, + {"key": "coherence", "count": 13, "avg": 0.81, "min": 0.54, "max": 0.98, "p50": 0.83}, + ], + "timeline": {"bucket_unit": "day", "points": []}, + } + output.render_eval_aggregate(data) + cap = capsys.readouterr() + out = cap.out + assert "eval-aggregate" in out and "324" in out and "320 done" in out + assert "98.8% success rate" in out # 320/324 + assert "score stats" in out and "2 metrics" in out and "sorted by avg" in out + # worst-avg first: helpfulness (.66) before coherence (.81) + assert out.index("helpfulness") < out.index("coherence") + assert "0.66" in out and "0.81" in out # avg keeps leading zero + # the band/scale legend prints under the panel (stderr chrome) + assert "scale .40–1.0" in cap.err and "≥.80" in cap.err + output.configure(no_color=True, quiet=False) + + +# --- errors list + aggregate ------------------------------------------------- + + +def _errevent(event_type, summary, ts="2026-06-22T17:51:34Z"): + # A light-feed (/events/summary) row: the server supplies `summary` precomputed; the CLI + # renders it directly and never parses the (absent) payload. + return AgentEvent(id=1, session_id="sess-20260622-4b90b240", agent_id="agent-orderbot", + event_type=event_type, ts=ts, environment="prod", summary=summary, + is_error=("error" in event_type or "fail" in event_type)) + + +def test_event_cell_red_only_on_error_substring(): + output.configure(no_color=False, quiet=False) + err = output._event_cell("error") + ok = output._event_cell("tool_result") + # the error type's spans are ERROR red; a neutral type has none + assert any(s.style == theme.ERROR for s in err.spans) + assert not any(s.style == theme.ERROR for s in ok.spans) + + +def test_render_errors_box(capsys): + _wide_stdout() + output.render_errors([_errevent("error", "RateLimitError: upstream timed out")]) + out = capsys.readouterr().out + assert "errors" in out and "newest first" in out + assert "agent-orderbot" in out and "sess-…4b90b240" in out # truncated session (shared last-8) + assert "RateLimitError: upstream timed out" in out # server-computed summary field + assert "17:51:34" in out + output.configure(no_color=True, quiet=False) + + +def test_render_errors_empty(capsys): + _wide_stdout() + output.render_errors([]) + assert "no errors" in capsys.readouterr().out + output.configure(no_color=True, quiet=False) + + +def test_render_error_aggregate_card(capsys): + _wide_stdout() + output.render_error_aggregate({"total": 66, "sessions": 62, "agents": 6, "last_ts": "2026-01-01T00:00:00Z", "bins": []}) + out = capsys.readouterr().out + assert "errors-aggregate" in out and "66" in out and "errored events" in out + assert "across" in out and "62 sessions" in out and "6 agents" in out + output.configure(no_color=True, quiet=False) + + +def test_render_error_aggregate_empty_is_healthy(capsys): + _wide_stdout() + output.render_error_aggregate({"total": 0, "sessions": 0, "agents": 0, "last_ts": None, "bins": []}) + out = capsys.readouterr().out + assert "no errors found" in out + output.configure(no_color=True, quiet=False) + + +def test_relative_age(): + assert output._relative_age(None) == "" + assert output._relative_age("not-a-ts") == "" + assert output._relative_age("2020-01-01T00:00:00Z").endswith("ago") # well in the past + + +# --- orgs: current card + perms + shared panels ----------------------------- + +_ORGS = [ + {"is_active": True, "slug": "globex", "name": "Globex Corp", "role": "admin", "perms": 28}, + {"is_active": False, "slug": "acme", "name": "Acme Corp", "role": "admin", "perms": 27}, +] + + +def test_render_orgs_list(capsys): + _wide_stdout() + output.render_orgs_list(_ORGS) + out = capsys.readouterr().out + assert "your orgs" in out and "· 2" in out + assert "globex" in out and "Globex Corp" in out and "acme" in out + assert "switch with fp orgs switch <slug>" in out # generic switch hint + output.configure(no_color=True, quiet=False) + + +def test_render_current_org_card(capsys): + _wide_stdout() + output.render_current_org(slug="globex", name="Globex Corp", role="admin", + permission_count=28, email="admin@local.host") + cap = capsys.readouterr() + out = cap.out + assert "current org" in out and "globex" in out and "Globex Corp" in out + assert "role admin" in out and "28 permissions" in out + assert "signed in as admin@local.host" in out + # footer cross-links the related commands (stderr chrome) + assert "fp orgs perms" in cap.err and "fp orgs switch <slug>" in cap.err + output.configure(no_color=True, quiet=False) + + +def test_render_org_perms(capsys): + _wide_stdout() + output.render_org_perms(slug="globex", role="admin", name="Globex Corp", + permissions=["dashboards:read", "dashboards:write", "keys:read", "keys:delete", "agent:use"]) + out = capsys.readouterr().out + # header leads with the org name + slug + role; the count moved into the box title + assert "Globex Corp" in out and "globex" in out and "role admin" in out + assert "permissions · 5 · Globex Corp" in out + assert "dashboards" in out and "keys" in out and "agent" in out + output.configure(no_color=True, quiet=False) + + +def test_permissions_panel_shared_with_whoami(): + # whoami and orgs perms render the SAME permissions component (no drift). + perms = ["keys:read", "keys:delete", "dashboards:read"] + a = output.render_permissions_panel(perms) + b = output.render_permissions_panel(perms) + assert type(a) is type(b) # same renderable type from the one shared helper + + +# --- list <kind> column-flow ------------------------------------------------ + + +def test_render_value_list_short_no_footer(capsys): + _wide_stdout() + output.render_value_list("envs", ["prod", "dev", "staging"], description="seen across events") + cap = capsys.readouterr() + out = cap.out + assert "envs · 3 seen across events" in out + for v in ("dev", "prod", "staging"): + assert v in out + # the filter-hint footer was removed from every list kind + assert "filter" not in cap.err + + +def test_render_value_list_columns_and_sorted(capsys): + _wide_stdout() + vals = [f"v{i:02d}" for i in range(20)] # 20 items, COL_HEIGHT 8 → 3 columns + output.render_value_list("tools", list(reversed(vals)), description="seen across events") + out = capsys.readouterr().out + assert "tools · 20" in out + assert all(v in out for v in vals) # every value shown (no truncation) + # sorted + column-major: col0 = v00..v07, so v00 appears before v08 (col1) and v16 (col2) + assert out.index("v00") < out.index("v08") < out.index("v16") + + +def test_render_value_list_empty(capsys): + _wide_stdout() + output.render_value_list("error_types", [], description="seen across events") + assert "none found" in capsys.readouterr().out + output.configure(no_color=True, quiet=False) + + +def test_render_value_list_never_has_footer(capsys): + _wide_stdout() + output.render_value_list("models", ["gpt", "claude"]) + assert "filter" not in capsys.readouterr().err # the footer was removed for all kinds + output.configure(no_color=True, quiet=False) + + +def test_render_value_list_narrow_caps_columns(capsys): + from rich.console import Console + output.configure(no_color=True, quiet=False) + output._stdout = Console(width=30, no_color=True) # very narrow + vals = [f"value-{i:02d}" for i in range(20)] + output.render_value_list("tools", vals) + out = capsys.readouterr().out + # never wider than the terminal; all values still present (taller, fewer columns) + assert all(len(line) <= 30 for line in out.splitlines()) + assert all(v in out for v in vals) + output.configure(no_color=True, quiet=False) + + +# --- keys: list box + status + destructive confirm/cancel/secret ------------- + + +def test_short_id(): + assert output._short_id("1f58376d-7947-9826") == "1f58…9826" + assert output._short_id("short") == "short" + + +def test_key_status_cell(): + output.configure(no_color=False, quiet=False) + act = output._key_status_cell("active") + rev = output._key_status_cell("revoked") + unk = output._key_status_cell("mystery") + assert "●" in act.plain and any(s.style == theme.SUCCESS for s in act.spans) # live = filled green + assert "○" in rev.plain and any(s.style == theme.ERROR for s in rev.spans) # dead = hollow red + assert "●" in unk.plain and any(s.style == theme.TEXT_DIM for s in unk.spans) # unknown = neutral + + +def test_render_keys_box_and_footer(capsys): + _wide_stdout() + keys = [ + ApiKey(id="1f58376d", name="admin", permissions=["a", "b"], created_at="2026-06-18T05:14:00Z"), + ApiKey(id="9c22aa01", name="old", permissions=["x"], created_at="2026-06-10T00:00:00Z", revoked_at="2026-06-12T00:00:00Z"), + ] + output.render_keys(keys) + output.keys_footer(keys) + cap = capsys.readouterr() + out = cap.out + assert "api keys · 2 · active first" in out + assert "admin" in out and "active" in out and "revoked" in out + assert "06-18 05:14" in out # compact created stamp + assert "1f58376d" not in out # id hidden by default + assert out.index("admin") < out.index("old") # active key sorts above the revoked one + assert "2 keys" in cap.err and "1 active" in cap.err and "1 revoked" in cap.err + output.configure(no_color=True, quiet=False) + + +def test_render_keys_show_id(capsys): + _wide_stdout() + output.render_keys([ApiKey(id="1f58376d-7947-9826", name="admin", created_at="2026-06-18T05:14:00Z")], show_id=True) + assert "1f58…9826" in capsys.readouterr().out # short id column when --show-id + output.configure(no_color=True, quiet=False) + + +def test_print_cancelled(capsys): + output.configure(no_color=True, quiet=False) + output.print_cancelled() + err = capsys.readouterr().err # boxed chrome → stderr + assert "cancelled" in err and "nothing changed" in err + + +def test_key_not_found(capsys): + output.configure(no_color=True, quiet=False) + output.key_not_found("admn") + err = capsys.readouterr().err # red error box → stderr + assert "error" in err and "no key named" in err and "admn" in err + assert "fp keys list" in err # hint + + +def test_render_secret_box(capsys): + output.configure(no_color=True, quiet=False) + output.render_secret_box("admin", "a" * 64) + err = capsys.readouterr().err + assert "secret rotated" in err and "new secret for key" in err and "admin" in err + assert "a" * 64 in err and "shown once" in err + output.configure(no_color=True, quiet=False) + + +def test_key_disabled_box(capsys): + output.configure(no_color=True, quiet=False) + output.key_disabled("admin") + err = capsys.readouterr().err # green boxed result → stderr (scripts use --json / exit code) + assert "disabled" in err and "admin" in err and "it can no longer be used" in err + + +# --- users: list box + identity cards + permission diff ---------------------- + + +def _du(**over) -> DashboardUser: + base = dict(id="u1", email="a@test", permissions=[], permission_set=None, + permission_added=[], permission_removed=[], disabled_at=None, + is_protected=False, created_at="2026-06-25T08:00:00Z", updated_at="") + base.update(over) + return DashboardUser(**base) + + +def test_fmt_user_joined(): + assert output._fmt_user_joined("2026-06-25T08:00:00Z", False) == "06-25" + assert output._fmt_user_joined("2026-06-25T08:00:00Z", True) == "2026-06-25" # spans years + assert output._fmt_user_joined("nope", False) == "-" + + +def test_user_status_cell(): + output.configure(no_color=False, quiet=False) + act = output._user_status_cell(False) + dis = output._user_status_cell(True) + assert "● active" in act.plain and any(s.style == theme.SUCCESS for s in act.spans) + assert "○ disabled" in dis.plain and any(s.style == theme.ERROR for s in dis.spans) + muted = output._user_status_cell(True, muted=True) + assert any(s.style == theme.TEXT_DIM for s in muted.spans) # dimmed in disabled list rows + output.configure(no_color=True, quiet=False) + + +def test_render_users_box_and_footer(capsys): + _wide_stdout() + users = [ + _du(id="u1", email="root@test", permissions=["events:read"] * 28, permission_set="admin", is_protected=True), + _du(id="u2", email="dev@test", permissions=["events:read"] * 9, permission_set="read-only"), + _du(id="u3", email="off@test", permission_set="standard", disabled_at="2026-01-01T00:00:00Z"), + ] + output.render_users(users) + output.users_footer(users) + cap = capsys.readouterr() + out, err = cap.out, cap.err + assert "users · 3" in out + for c in ("email", "access", "perms", "joined", "status"): + assert c in out + assert "root@test" in out and "admin" in out and "06-25" in out + assert "active" in out and "disabled" in out + assert "P" in out # protected lock fallback (no-color → P marker) + assert "3 users" in err and "2 active" in err and "1 disabled" in err and "1 protected" in err + output.configure(no_color=True, quiet=False) + + +def test_render_users_active_sorted_first(capsys): + _wide_stdout() + output.render_users([ + _du(id="u2", email="off@test", disabled_at="2026-01-01T00:00:00Z"), + _du(id="u1", email="on@test", disabled_at=None), + ]) + out = capsys.readouterr().out + assert out.index("on@test") < out.index("off@test") # active above disabled + output.configure(no_color=True, quiet=False) + + +def test_render_users_empty(capsys): + _wide_stdout() + output.render_users([]) + assert "no users" in capsys.readouterr().out + output.configure(no_color=True, quiet=False) + + +def test_render_user_show_card_and_perms(capsys): + _wide_stdout() + u = _du(email="dev@test", permissions=["keys:read", "keys:delete", "dashboards:read"], + permission_set="admin", is_protected=True) + output.render_user_show(u) + out = capsys.readouterr().out # show is a read view → stdout + assert "user" in out and "dev@test" in out and "protected" in out + assert "access admin" in out and "3 permissions" in out and "active" in out + assert "permissions · 3" in out and "keys" in out and "dashboards" in out + output.configure(no_color=True, quiet=False) + + +def test_render_user_created_green_card(capsys): + _wide_stdout() + u = _du(email="new@test", permissions=["events:read", "keys:read"], permission_set="read-only") + output.render_user_created(u) + err = capsys.readouterr().err # write result → stderr chrome + assert "user created" in err and "new@test" in err + assert "access read-only" in err and "2 permissions" in err and "active" in err + assert "permissions · 2 · read-only" in err # suffix shows the set + output.configure(no_color=True, quiet=False) + + +def test_render_user_updated_diff(capsys): + _wide_stdout() # no-color → +/- prefixes for the diff + result = _du(email="dev@test", permissions=["users:read", "users:create", "keys:read"], permission_set="admin") + union = sorted({"users:read", "users:write", "keys:read"} | {"users:read", "users:create", "keys:read"}) + output.render_user_updated(result, added=["users:create"], removed=["users:write"], union=union) + err = capsys.readouterr().err + assert "permissions updated · dev@test" in err + assert "now 3" in err and "+1 added" in err and "−1 removed" in err + assert "permissions · 3" in err # NEW set size (struck removal not counted) + assert "+create" in err and "-write" in err # added chip / removed ghost (no-color prefixes) + assert "unchanged" in err # legend + output.configure(no_color=True, quiet=False) + + +def test_user_notice_boxes(capsys): + output.configure(no_color=True, quiet=False) + output.user_not_found("ghost@test") + output.user_disabled("dev@test") + output.user_enabled("dev@test") + output.user_no_change() + err = capsys.readouterr().err # all notice boxes → stderr + assert "no user with email" in err and "ghost@test" in err and "fp users list" in err + assert "can no longer sign in" in err and "fp users enable dev@test" in err + assert "they can sign in again" in err + assert "no change" in err and "already match" in err + + +# --- saved queries: list box + show (card + highlighted SQL) ---------------- + + +def _sq(**over) -> SavedQuery: + base = dict(id="q1", name="errs", description="", sql_text="select 1", params=[], + created_by="system", created_at="2026-06-18T05:14:51Z", updated_at="") + base.update(over) + return SavedQuery(**base) + + +def test_render_queries_box(capsys): + _wide_stdout() + qs = [ + _sq(name="q_eval_score_avg", description="Average value for each score key. " * 6, created_by="system"), + _sq(name="q_eval_total", description="Count by KPI tiles.", created_by="alice@example.com"), + ] + output.render_queries(qs) + cap = capsys.readouterr() + out, err = cap.out, cap.err + assert "saved queries · 2" in out # the count glows in the title + for c in ("name", "description", "created by", "created"): + assert c in out + assert "q_eval_score_avg" in out and "06-18" in out + assert "…" in out # long description truncated to one line + assert "updated" not in out # updated_at not shown + assert "run one with" not in err # the run-hint footer was removed + + +def test_render_queries_empty(capsys): + _wide_stdout() + output.render_queries([]) + assert "no saved queries" in capsys.readouterr().out + output.configure(no_color=True, quiet=False) + + +def test_render_query_show_card_and_sql(capsys): + _wide_stdout() + q = _sq(name="q_eval_score_avg", description="Average value for each score key.", + sql_text="SELECT score_key,\n avg(score_val) AS value\nFROM analytics.evaluations") + output.render_query_show(q) + out = capsys.readouterr().out + assert "q_eval_score_avg · saved query" in out + assert "Average value for each score key." in out # full description + assert "created by system · created 2026-06-18" in out # created_at, not updated_at + assert "sql · clickhouse" in out + assert "SELECT" in out and "avg" in out and "FROM" in out # full SQL, no truncation + assert "1" in out and "2" in out and "3" in out # line numbers + output.configure(no_color=True, quiet=False) + + +def test_query_not_found_boxed(capsys): + _wide_stdout() + output.query_not_found("nope") + err = capsys.readouterr().err # red `error` notice box + assert "error" in err and "no query named" in err and "nope" in err and "fp query list" in err + output.configure(no_color=True, quiet=False) + + +def test_query_write_feedback_boxed(capsys): + _wide_stdout() + output.query_exists("dup") + output.query_failed("Syntax error near 'FORM'") + output.query_deleted("errs") + output.query_cancelled("nothing deleted") + err = capsys.readouterr().err + assert "a query named" in err and "dup" in err and "fp query update dup" in err + assert "query failed — Syntax error near 'FORM'" in err and "check your query and rerun it" in err + assert "deleted saved query" in err and "errs" in err + assert "cancelled" in err and "nothing deleted" in err # boxed (title `cancelled` + body) + output.configure(no_color=True, quiet=False) + + +def test_query_failed_permission_has_no_query_hint(capsys): + _wide_stdout() + output.query_failed("Your account lacks permission", permission=True) + err = capsys.readouterr().err + assert "Your account lacks permission" in err + assert "check your query" not in err # permission error → no query-fix hint + output.configure(no_color=True, quiet=False) + + +def test_render_query_created_and_updated(capsys): + _wide_stdout() + q = _sq(name="sample_query_1", description="sample query", sql_text="SELECT * FROM tables") + output.render_query_created(q) + err = capsys.readouterr().err # write result → stderr + assert "query created" in err and "sample_query_1" in err and "sample query" in err + assert "created by you · just now" in err + assert "SELECT" in err and "FROM" in err # numbered sql box + assert "run it with" not in err # the run-hint footer was removed + + q2 = _sq(name="sample_query1", description="hi", sql_text="SELECT 1") + output.render_query_updated(q2, old_name="sample_query_1") + err2 = capsys.readouterr().err + assert "query updated" in err2 and "sample_query1" in err2 and "was sample_query_1" in err2 # rename shown + output.configure(no_color=True, quiet=False) + + +def test_render_query_delete_preview(capsys): + output.configure(no_color=True, quiet=False) + q = _sq(name="q_eval_total", description="Count of evaluations matching filters. Used by KPI tiles.", + created_by="system") + output.render_query_delete_preview(q) + err = capsys.readouterr().err # amber preview box → stderr + assert "delete saved query" in err and "q_eval_total" in err + assert "created by system · 2026-06-18" in err + + +def _qr(columns, rows, elapsed_ms=12) -> QueryResult: + return QueryResult(columns=columns, rows=rows, truncated=False, elapsed_ms=elapsed_ms) + + +def test_render_query_result_scalar(capsys): + _wide_stdout() + output.render_query_result("q_eval_total", _qr([{"name": "total", "type": "UInt64"}], [["1284"]], 6)) + out = capsys.readouterr().out + assert "q_eval_total · 1 row · 6ms" in out + assert "1,284" in out and "total" in out # thousands separator + column-name label + output.configure(no_color=True, quiet=False) + + +def test_render_query_result_record(capsys): + _wide_stdout() + res = _qr([{"name": "session", "type": "String"}, {"name": "score", "type": "Float64"}], + [["sess-2026-4b90", "0.912"]], 8) + output.render_query_result("latest_run", res) + out = capsys.readouterr().out + assert "latest_run · 1 row · 8ms" in out + assert "session" in out and "sess-2026-4b90" in out and "score" in out and "0.912" in out + output.configure(no_color=True, quiet=False) + + +def test_render_query_result_table_and_null(capsys): + _wide_stdout() + res = _qr([{"name": "score_key", "type": "String"}, {"name": "value", "type": "Float64"}, {"name": "n", "type": "UInt64"}], + [["helpfulness", "0.847", "285"], [None, "0.503", "11"]], 12) + output.render_query_result("q_eval_score_avg", res) + cap = capsys.readouterr() + out = cap.out + assert "q_eval_score_avg · 2 rows · 12ms" in out + assert "helpfulness" in out and "0.847" in out and "285" in out + assert "null" in out # None cell → 'null', not '-' + assert "2 rows" in cap.err and "3 columns" in cap.err # footer + output.configure(no_color=True, quiet=False) + + +def test_render_query_result_empty(capsys): + _wide_stdout() + output.render_query_result("sample_query", _qr([{"name": "n", "type": "UInt64"}], [], 3)) + assert "no rows returned" in capsys.readouterr().out + output.configure(no_color=True, quiet=False) + + +def test_render_query_result_row_cap(capsys): + _wide_stdout() + rows = [[str(i), str(i * 2)] for i in range(120)] + res = _qr([{"name": "a", "type": "UInt64"}, {"name": "b", "type": "UInt64"}], rows, 50) + output.render_query_result("big", res, row_cap=50) + cap = capsys.readouterr() + assert "showing 50 of 120 rows" in cap.err and "query run" in cap.err # capped footer + --json pointer + output.configure(no_color=True, quiet=False) + + +def test_render_query_schema(capsys): + _wide_stdout() + data = {"schema": "analytics", "tables": [ + {"name": "events", "columns": [{"name": "id", "type": "int"}, {"name": "tool_name", "type": "string?"}]}, + {"name": "evaluations", "columns": [{"name": "status", "type": "string"}]}, + ]} + output.render_query_schema(data) + output.schema_footer(2, 3) + cap = capsys.readouterr() + out, err = cap.out, cap.err + assert "schema · analytics · 2 tables · 3 columns" in out + assert "events" in out and "evaluations" in out + assert out.count("events") == 1 # table name printed once per group (not per column) + assert "tool_name" in out and "?" in out # nullable marker + assert "2 tables" in err and "nullable" in err # footer legend + output.configure(no_color=True, quiet=False) + + +def test_schema_type_cell_categories(): + output.configure(no_color=False, quiet=False) + assert output._schema_type_cell("int").style == theme.PINK + assert output._schema_type_cell("string").style == theme.SUCCESS + assert output._schema_type_cell("uuid").style == theme.BLUE + assert output._schema_type_cell("timestamp").style == theme.BLUE + assert output._schema_type_cell("Bool").style == theme.AMBER + nullable = output._schema_type_cell("string?") + assert nullable.plain == "string ?" and nullable.style == theme.SUCCESS # base green + dim ? split off + output.configure(no_color=True, quiet=False) + + +# --- alerts: list box + show cards (per-trigger-kind parsing) ---------------- + + +def _alert(**over) -> Alert: + base = dict(id="a1", name="alert", description=None, enabled=True, trigger_kind="metric_threshold", + trigger_spec={}, min_breaches=1, eval_window=1, eval_interval_secs=300, severity="warning", + channels=[], created_by="admin@local.host", created_at="2026-06-28T00:00:00Z", + updated_at="", last_attempted_at="2026-06-28T00:00:00Z", open_incidents=0) + base.update(over) + return Alert(**base) + + +def test_humanize_secs(): + assert output.humanize_secs(300) == "5m" + assert output.humanize_secs(900) == "15m" + assert output.humanize_secs(3600) == "1h" + assert output.humanize_secs(86400) == "1d" + assert output.humanize_secs(45) == "45s" + assert output.humanize_secs(60) == "1m" + assert output.humanize_secs(None) == "-" + + +def test_severity_and_status_cells(): + output.configure(no_color=False, quiet=False) + assert output._severity_cell("critical").style == theme.ERROR + assert output._severity_cell("warning").style == theme.AMBER + assert output._severity_cell("info").style == theme.TEXT_DIM + assert output._severity_cell("weird").style == theme.TEXT_DIM # unknown → neutral + on = output._alert_status_cell(True) + off = output._alert_status_cell(False) + assert "● on" in on.plain and any(s.style == theme.SUCCESS for s in on.spans) + assert "○ off" in off.plain + output.configure(no_color=True, quiet=False) + + +def test_render_alerts_box_and_footer(capsys): + _wide_stdout() + alerts = [ + _alert(name="live", trigger_kind="custom_sql", severity="critical", enabled=True, open_incidents=1), + _alert(name="off1", trigger_kind="metric_threshold", severity="warning", enabled=False, + last_attempted_at=None, created_at="2026-06-20T00:00:00Z"), + ] + output.render_alerts(alerts) + output.alerts_footer(alerts) + cap = capsys.readouterr() + out, err = cap.out, cap.err + assert "alerts · 2 · newest first" in out + for c in ("created", "name", "by", "trigger", "severity", "last alert"): # no status column + assert c in out + assert "status" not in out # the status column was removed + assert "live" in out and "admin@local.host" in out # the actual creator, not "you" + assert "never" in out + assert out.index("live") < out.index("off1") # newest first + assert "2 alerts" in err and "1 on" in err and "1 off" in err # on/off split in the footer + assert "1 critical" in err and "1 warning" in err + output.configure(no_color=True, quiet=False) + + +def test_render_alert_show_metric_threshold(capsys): + _wide_stdout() + a = _alert(name="metric-threshold-alert", trigger_kind="metric_threshold", severity="warning", + trigger_spec={"filter": {"environment": "production", "event_type": "tool_call"}, + "metric": "error_count", "op": ">", "value": 50, "window_secs": 900}, + channels=[]) + output.render_alert_show(a) + out = capsys.readouterr().out + assert "metric-threshold-alert" in out and "warning" in out and "enabled" in out + assert "0 open incidents" in out + assert "trigger · metric threshold" in out + assert "fire when error_count > 50 over 15m" in out + assert "environment = production" in out and "event_type = tool_call" in out + assert "window 1" in out and "min breaches 1" in out and "checks every 5m" in out + assert "channels · default" in out and "slack" in out and "default webhook" in out + output.configure(no_color=True, quiet=False) + + +def test_render_alert_show_custom_sql(capsys): + _wide_stdout() + a = _alert(trigger_kind="custom_sql", + trigger_spec={"op": ">", "query_name": "sample-query", "sql": "SELECT model\nFROM analytics.events", "value": 10}) + output.render_alert_show(a) + out = capsys.readouterr().out + assert "fire when query sample-query > 10 rows" in out + assert "SELECT" in out and "FROM" in out # SQL via Syntax box inside the card + output.configure(no_color=True, quiet=False) + + +def test_render_alert_show_evaluation_score(capsys): + _wide_stdout() + a = _alert(trigger_kind="evaluation_score", + trigger_spec={"environment": "dev", "min_count": 3, "op": ">", "score_key": "hallucination", "value": 0.8, "window_secs": 3600}) + output.render_alert_show(a) + out = capsys.readouterr().out + assert "fire when hallucination > 0.8 (min 3) over 1h" in out + assert "environment" in out and "dev" in out + output.configure(no_color=True, quiet=False) + + +def test_render_alert_show_per_event(capsys): + _wide_stdout() + a = _alert(trigger_kind="per_event", + trigger_spec={"agent_id": "agent_id", "environment": "dev", "error_type": "RuntimeError", + "event_type": "error", "lookback_secs": 60, "message_contains": "runtimeerror", "tool_name": "bash"}) + output.render_alert_show(a) + out = capsys.readouterr().out + assert "fire on error events within 1m" in out + assert "tool_name" in out and "bash" in out and "error_type" in out and "RuntimeError" in out + assert 'message ~' in out and "runtimeerror" in out + output.configure(no_color=True, quiet=False) + + +def test_render_alert_show_eval_compound(capsys): + _wide_stdout() + a = _alert(trigger_kind="eval_compound", + trigger_spec={"combinator": "any", "window_secs": 3600, "min_count": 1, "environment": "dev", + "conditions": [{"score_key": "helpfulness", "op": "<", "value": 0.5}, + {"score_key": "safety", "op": "<", "value": 0.8}]}) + output.render_alert_show(a) + out = capsys.readouterr().out + assert "fire when any of these over 1h:" in out + assert "helpfulness < 0.5" in out and "safety < 0.8" in out + assert "min count 1" in out and "environment" in out and "dev" in out + output.configure(no_color=True, quiet=False) + + +def test_render_alert_channels_custom_and_default(): + output.configure(no_color=False, quiet=False) + # empty → all defaults + all_def, _ = output._alert_channels_body([]) + assert all_def is True + # a custom slack + default email → not all-default + mixed, _ = output._alert_channels_body([ + {"kind": "slack", "webhook_setting_key": "my_slack_url_entered"}, + {"kind": "email", "recipients": None}, + ]) + assert mixed is False + # alerts.-prefixed key counts as default + defaulted, _ = output._alert_channels_body([{"kind": "slack", "webhook_setting_key": "alerts.slack_default_webhook"}]) + assert defaulted is True + output.configure(no_color=True, quiet=False) + + +def test_render_alert_created_and_updated(capsys): + _wide_stdout() + a = _alert(name="errs", trigger_kind="metric_threshold", severity="warning", enabled=True, + trigger_spec={"metric": "error_count", "op": ">", "value": 50, "window_secs": 900}) + output.render_alert_created(a) + out = capsys.readouterr().out # write result → stdout (data) + assert "alert created" in out and "errs" in out + assert "warning" in out and "enabled" in out # identity line + assert "created by you · just now" in out + assert "trigger · metric threshold" in out and "fire when error_count > 50 over 15m" in out + assert "evaluation" in out and "channels" in out # full config cards + + b = _alert(name="errs2", trigger_kind="metric_threshold", severity="critical") + output.render_alert_updated(b, old_name="errs") + out2 = capsys.readouterr().out + assert "alert updated" in out2 and "errs2" in out2 and "was errs" in out2 # rename shown + assert "updated by you · just now" in out2 + output.configure(no_color=True, quiet=False) + + +def test_render_alert_delete_preview_and_feedback(capsys): + output.configure(no_color=True, quiet=False) + a = _alert(name="old-alert", severity="warning", open_incidents=2) + output.render_alert_delete_preview(a) + output.alert_deleted("old-alert") + output.cancelled_plain("nothing deleted") + output.alert_not_found("ghost") + err = capsys.readouterr().err + assert "delete alert" in err and "old-alert" in err and "2 open incidents" in err + assert "deleted alert old-alert" in err + assert "cancelled — nothing deleted" in err + assert "no alert named" in err and "ghost" in err and "fp alerts list" in err + + +def test_alert_exists_and_test_sent(capsys): + output.configure(no_color=True, quiet=False) + output.alert_exists("dup") + output.alert_test_sent("p95", ["slack", "email"]) + err = capsys.readouterr().err + assert "an alert named" in err and "dup" in err and "fp alerts update dup" in err + assert "test notification sent for" in err and "p95" in err + assert "dispatched to" in err and "slack" in err and "email" in err + assert "delivery isn't confirmed" in err + + +# --- settings: list box + schema box + set card ----------------------------- + + +class _Setting: + def __init__(self, key, value, schema=None, updated_at="2026-06-25T16:00:00Z", updated_by=None): + self.key, self.value, self.schema = key, value, schema or {} + self.updated_at, self.updated_by, self.scope = updated_at, updated_by, None + + +def test_setting_value_text_type_aware(): + output.configure(no_color=False, quiet=False) + assert output._setting_value_text(86400, "positive_int").style == theme.PINK # numeric pink + assert output._setting_value_text(["a", "b"], "email_list").plain == "a, b" # list joined + assert output._setting_value_text("x", "secret").plain == "(secret)" # secret masked + assert output._setting_value_text("", "url").plain == "(unset)" # empty + assert output._setting_value_text([], "channel_set").plain == "(none)" # empty list + output.configure(no_color=True, quiet=False) + + +def test_render_settings_box(capsys): + _wide_stdout() + rows = [ + _Setting("session_ttl_secs", 86400, {"kind": "positive_int"}), + _Setting("alerts.webhook_signing_secret", "", {"kind": "secret"}), + _Setting("alerts.email_default_recipients", ["admin@local.host"], {"kind": "email_list"}), + ] + output.render_settings(rows) + cap = capsys.readouterr() + out, err = cap.out, cap.err + assert "settings · 3" in out # the count glows in the title + for c in ("key", "value", "type", "updated"): + assert c in out + assert "session_ttl_secs" in out and "86400" in out and "integer" in out + assert "(secret)" in out # secret never echoed + assert "change one with" not in err # the footer hint was removed + output.configure(no_color=True, quiet=False) + + +def test_render_settings_schema_accepts(capsys): + _wide_stdout() + entries = [ + {"key": "session_ttl_secs", "kind": "positive_int", "min": 60, "max": 2592000, "unit": "seconds", + "description": "session lifetime"}, + {"key": "alerts.enabled_channels", "kind": "channel_set", "options": ["email", "slack", "webhook"], + "description": "channels"}, + ] + output.render_settings_schema(entries) + out = capsys.readouterr().out + assert "settings schema · 2" in out + assert "60–2592000 seconds" in out # int range + unit + assert "email · slack · webhook" in out # channel options + output.configure(no_color=True, quiet=False) + + +def test_render_setting_updated_card(capsys): + _wide_stdout() + output.render_setting_updated(_Setting("session_ttl_secs", 3600, {"kind": "positive_int"}), "positive_int") + out = capsys.readouterr().out # write result → stdout + assert "setting updated" in out and "session_ttl_secs" in out and "3600" in out + assert "updated by you · just now" in out + output.configure(no_color=True, quiet=False) + + +def test_setting_feedback_lines(capsys): + output.configure(no_color=True, quiet=False) + output.setting_not_found("nope") + output.setting_no_change("session_ttl_secs", 86400, "positive_int") + output.setting_failed("value must be between 60 and 2592000") + err = capsys.readouterr().err + assert "no setting named" in err and "nope" in err and "fp settings list" in err + assert "no change" in err and "session_ttl_secs" in err and "already" in err + assert "value must be between 60 and 2592000" in err + + +# ══ incidents output tests (added) ══ + + +def _incident(**over) -> Incident: + base = {"id": "1f5803aaaaaabbbbcccc000000009826", "alert_name": "p95 latency", + "alert_severity": "critical", "state": "firing", "opened_at": "2026-06-20T00:00:00Z", + "assignees": []} + base.update(over) + return Incident.from_dict(base) + + +def test_incident_status_cell_enum(): + output.configure(no_color=False, quiet=False) + fire = output._incident_status_cell("firing") + assert fire.plain == "● firing" and any(s.style == theme.ERROR for s in fire.spans) + ack = output._incident_status_cell("acknowledged") + assert ack.plain == "● acknowledged" and any(s.style == theme.AMBER for s in ack.spans) + res = output._incident_status_cell("resolved") + assert res.plain == "○ resolved" # hollow dot → distinguishable mono + assert output._incident_status_cell("weird").plain == "● weird" # unknown → neutral, no crash + output.configure(no_color=True, quiet=False) + + +def test_assignees_cell_overflow(): + output.configure(no_color=True, quiet=False) + assert output._assignees_cell([]).plain == "—" + assert output._assignees_cell(["a@x", "b@x", "c@x", "d@x"]).plain == "a@x, b@x +2" + + +def test_render_incidents_box_and_footer(capsys): + _wide_stdout() + incs = [ + _incident(state="firing", assignees=["a@example.com"]), + _incident(id="2266", alert_name=None, alert_severity="warning", state="acknowledged"), + _incident(id="3399", alert_severity="info", state="resolved", opened_at="2026-06-18T00:00:00Z"), + ] + output.render_incidents(incs) + output.incidents_footer(incs) + cap = capsys.readouterr() + out, err = cap.out, cap.err + assert "issues · 3" in out + for c in ("id", "alert", "severity", "state", "opened", "assignees"): + assert c in out + assert "1f58…9826" in out # short id (the handle) + assert "—" in out # manual incident (no alert_name) + no assignees + assert "firing" in out and "acknowledged" in out and "resolved" in out + assert "3 issues" in err and "1 firing" in err and "1 resolved" in err # footer distribution + output.configure(no_color=True, quiet=False) + + +def test_render_incidents_show_id_full(capsys): + _wide_stdout() + output.render_incidents([_incident()], show_id=True) + out = capsys.readouterr().out + assert "1f5803aaaaaabbbbcccc000000009826" in out # full id when --show-id + output.configure(no_color=True, quiet=False) + + +def test_render_incident_count_card(capsys): + _wide_stdout() + output.render_incident_count(12) + output.render_incident_count(4, state="firing") + out = capsys.readouterr().out + assert "issues" in out and "12" in out and "open issues" in out + assert "4" in out and "firing issues" in out + output.configure(no_color=True, quiet=False) + + +def test_render_incident_show_sections(capsys): + _wide_stdout() + inc = _incident( + state="acknowledged", acknowledged_by="ops@example.com", assignees=["a@example.com"], + breach_summary="p95 = 1240ms > 1000ms", + comments=[{"author_email": "ops@example.com", "body": "on it", "created_at": "2026-06-20T00:01:00Z"}, + {"author_email": "x@example.com", "body": None, "created_at": "t", "deleted_at": "t"}], + subscribers=[{"email": "ops@example.com", "source": "ack", "subscribed_at": "2026-06-20T00:01:00Z"}], + activity=[{"kind": "opened", "actor": "system", "at": "2026-06-20T00:00:00Z"}], + ) + output.render_incident_show(inc) + out = capsys.readouterr().out + assert "p95 latency" in out and "1f58…9826" in out + assert "acknowledged by ops@example.com" in out and "assigned to a@example.com" in out + assert "breach" in out and "1240ms" in out + assert "comments · 2" in out and "on it" in out and "(deleted)" in out + assert "subscribers · 1" in out + assert "activity · 1" in out and "opened" in out and "system" in out + output.configure(no_color=True, quiet=False) + + +def test_render_incident_show_omits_empty_sections_and_uses_breach_value(capsys): + _wide_stdout() + output.render_incident_show(_incident(alert_name=None, title="checkout 500s", + source="manual", breach_value=1240.0)) + out = capsys.readouterr().out + # The header used to be hardcoded to the literal "manual incident" for any + # issue without a parent alert, which said nothing and mislabelled every + # audit-born issue. It now shows the issue's own title and real source. + assert "checkout 500s" in out and "manual incident" not in out + assert "manual" in out and "breach value 1240" in out + assert "comments" not in out and "subscribers" not in out and "activity" not in out + output.configure(no_color=True, quiet=False) + + +def test_incident_model_carries_title_source_and_finding_id(): + """These three shipped with the issues redesign but `from_dict` dropped + them, so the CLI could not see the field that actually identifies a row.""" + inc = Incident.from_dict({"id": "i1", "title": "checkout 500s", "source": "audit", + "source_finding_id": "f7", "state": "firing"}) + assert inc.title == "checkout 500s" + assert inc.source == "audit" and inc.source_finding_id == "f7" + + +def test_render_incidents_distinguishes_rows_by_title(capsys): + """Only a minority of issues have an alert_name, so titling the table by + alert left every manual and audit-born row rendering as a bare '—'.""" + _wide_stdout() + output.render_incidents([ + _incident(id="a" * 32, title="checkout 500s", source="manual", alert_name=None), + _incident(id="b" * 32, title="retry storm in planner", source="audit", alert_name=None), + _incident(id="c" * 32, title="p95 latency", source="alert", alert_name="p95 latency"), + ]) + out = capsys.readouterr().out + assert "checkout 500s" in out and "retry storm in planner" in out + assert "manual" in out and "audit" in out and "alert" in out + output.configure(no_color=True, quiet=False) + + +def test_render_incident_opened_and_comment_added_cards(capsys): + _wide_stdout() + output.render_incident_opened(summary="manual page", severity="critical", state="firing") + output.render_incident_comment_added(IncidentComment.from_dict( + {"id": "c1", "author_email": "me@test", "body": "looking", "created_at": "t"})) + out = capsys.readouterr().out # write-result cards → stdout + assert "issue opened" in out and "manual page" in out and "opened by you · just now" in out + assert "comment added" in out and "by me@test · just now" in out and "looking" in out + output.configure(no_color=True, quiet=False) + + +def test_render_incident_comment_delete_preview(capsys): + _wide_stdout() + output.render_incident_comment_delete_preview(IncidentComment.from_dict( + {"id": "c1", "author_email": "x@example.com", "body": "wrong incident", "created_at": "2026-06-20T00:00:00Z"})) + err = capsys.readouterr().err # preview is stderr chrome + assert "delete comment" in err and "x@example.com" in err and "wrong incident" in err + output.configure(no_color=True, quiet=False) + + +def test_render_incident_comments_and_subscribers_boxes(capsys): + _wide_stdout() + output.render_incident_comments([ + IncidentComment.from_dict({"id": "c1", "author_email": "ops@example.com", "body": "db pool", "created_at": "2026-06-20T00:00:00Z"}), + IncidentComment.from_dict({"id": "c2", "author_email": "x@example.com", "body": None, "created_at": "t", "deleted_at": "t"}), + ]) + output.render_incident_subscribers([ + IncidentSubscriber.from_dict({"email": "ops@example.com", "source": "creator", "subscribed_at": "2026-06-20T00:00:00Z"})]) + out = capsys.readouterr().out + assert "comments · 2" in out and "db pool" in out and "(deleted)" in out + assert "subscribers · 1" in out and "ops@example.com" in out and "creator" in out + output.configure(no_color=True, quiet=False) + + +def test_confirm_incident_resolve_headline(capsys, monkeypatch): + output.configure(no_color=True, quiet=False) + monkeypatch.setattr(output.typer, "confirm", lambda *a, **k: False) + assert output.confirm_incident_resolve("1f5803aaaaaabbbbcccc000000009826", "p95 latency") is False + err = capsys.readouterr().err + assert "resolve issue" in err and "1f58…9826" in err and "(p95 latency)" in err and "this closes it" in err + + +def test_incident_plain_feedback_lines(capsys): + output.configure(no_color=True, quiet=False) + iid = "1f5803aaaaaabbbbcccc000000009826" + output.incident_acked(iid) + output.incident_resolved(iid) + output.incident_assigned(iid, ["a@example.com", "b@example.com"]) + output.incident_assigned(iid, []) + output.incident_subscribed(iid, None) + output.incident_unsubscribed(iid, "x@y.z") + output.incident_comment_deleted() + output.incident_not_found(iid) + output.incident_comment_not_found("c0ffee001111") + output.incident_failed("a@x.com is not an operator") + err = capsys.readouterr().err + assert "acknowledged issue 1f58…9826" in err + assert "resolved issue 1f58…9826" in err + assert "assigned 1f58…9826" in err and "a@example.com, b@example.com" in err + assert "cleared assignees on 1f58…9826" in err + assert "subscribed you to issue" in err and "unsubscribed x@y.z from issue" in err + assert "deleted comment" in err + assert "no issue 1f58…9826" in err and "fp issues list" in err + assert "no comment" in err and "c0ff…1111" in err + assert "a@x.com is not an operator" in err + + +# ══ agent output tests (added) ══ + + +def test_msg_text_extracts_str_and_dict(): + assert output._msg_text("plain") == "plain" + assert output._msg_text({"text": "wrapped"}) == "wrapped" + assert output._msg_text({"foo": 1}) == "" + assert output._msg_text(None) == "" + + +def test_render_agent_health_configured(capsys): + _wide_stdout() + output.render_agent_health(configured=True, default_model="claude-x", model_count=3) + out = capsys.readouterr().out # data view → stdout + assert "assistant" in out and "configured" in out + assert "default model claude-x" in out and "3 models available" in out + output.configure(no_color=True, quiet=False) + + +def test_render_agent_health_not_configured_omits_optional_lines(capsys): + _wide_stdout() + output.render_agent_health(configured=False, default_model=None, model_count=0) + out = capsys.readouterr().out + assert "not configured" in out + assert "default model" not in out and "available" not in out # omitted when none + output.configure(no_color=True, quiet=False) + + +def test_render_agent_models_marks_default(capsys): + _wide_stdout() + output.render_agent_models(["m-default", "m-fast"], default_model="m-default") + out = capsys.readouterr().out + assert "models · 2" in out and "m-default" in out and "m-fast" in out and "default" in out + output.configure(no_color=True, quiet=False) + + +def test_render_agent_models_empty(capsys): + _wide_stdout() + output.render_agent_models([], default_model=None) + out = capsys.readouterr().out + assert "models · 0" in out and "no models reported" in out + output.configure(no_color=True, quiet=False) + + +def test_render_agent_chats_box(capsys): + _wide_stdout() + chats = [ + {"id": "07854990-dade-4dea-aaaa", "title": "older", "message_count": 2, "updated_at": "2026-06-20T10:00:00Z"}, + {"id": "17bf35c3-3a5a-4ff7-bbbb", "title": "newer", "message_count": 5, "updated_at": "2026-06-27T10:00:00Z"}, + ] + output.render_agent_chats(chats) + cap = capsys.readouterr() + out, err = cap.out, cap.err + assert "chats · 2" in out # the count glows in the title + for c in ("chat-id", "title", "messages", "updated"): + assert c in out + assert "older" in out and "newer" in out + assert "07854990" in out and "17bf35c3" in out # short copy-friendly handle (first 8) + assert "dade" not in out # the rest of the id is not shown + assert out.index("newer") < out.index("older") # newest first + assert "open one with" not in err # the footer hint was removed + output.configure(no_color=True, quiet=False) + + +def test_render_agent_chats_empty(capsys): + _wide_stdout() + output.render_agent_chats([]) + assert "no chats" in capsys.readouterr().out + output.configure(no_color=True, quiet=False) + + +def test_render_agent_show_thread(capsys): + _wide_stdout() + output.render_agent_show(title="perf review", chat_id="conv-123456789", messages=[ + {"role": "user", "content": {"text": "why slow?"}}, + {"role": "assistant", "content": "because cache"}, + ]) + out = capsys.readouterr().out # transcript → stdout + assert "perf review" in out and "2 messages" in out + assert "you" in out and "assistant" in out + assert "why slow?" in out and "because cache" in out + output.configure(no_color=True, quiet=False) + + +def test_render_agent_show_empty(capsys): + _wide_stdout() + output.render_agent_show(title="", chat_id="c1", messages=[]) + out = capsys.readouterr().out + assert "untitled" in out and "no messages yet" in out + output.configure(no_color=True, quiet=False) + + +def test_render_agent_renamed_card(capsys): + _wide_stdout() + output.render_agent_renamed(chat_id="c1", title="new title", old_title="old title") + err = capsys.readouterr().err # write result → stderr + assert "chat renamed" in err and "new title" in err and "was old title" in err + output.configure(no_color=True, quiet=False) + + +def test_render_agent_delete_preview_and_feedback(capsys): + _wide_stdout() + output.render_agent_delete_preview(title="perf review", message_count=4, chat_id="07854990-dade") + output.agent_deleted("perf review") + output.print_cancelled("nothing deleted") # boxed cancel, like the command uses + err = capsys.readouterr().err + assert "delete chat" in err and "perf review" in err and "4 messages" in err + assert "07854990" in err and "dade" not in err # short id in the preview + assert "deleted chat" in err and "perf review" in err # boxed deleted + assert "cancelled" in err and "nothing deleted" in err # boxed cancel + output.configure(no_color=True, quiet=False) + + +def test_confirm_agent_delete(monkeypatch, capsys): + _wide_stdout() + monkeypatch.setattr("typer.confirm", lambda *a, **k: True) + assert output.confirm_agent_delete() is True + monkeypatch.setattr("typer.confirm", lambda *a, **k: False) + assert output.confirm_agent_delete() is False + assert "permanently removes the chat" in capsys.readouterr().err + output.configure(no_color=True, quiet=False) + + +def test_agent_ask_chrome_lines(capsys): + _wide_stdout() + output.agent_tool_used("run_query") + output.render_agent_new_chat("07854990-dade-4dea") + output.agent_error("assistant error: boom") + output.agent_chat_not_found("07854990-dade-4dea") + output.agent_unconfigured_note() + err = capsys.readouterr().err + assert "used tool: run_query" in err + assert "new chat" in err and 'fp agent ask --chat 07854990 "…"' in err # short id, positional msg + assert "assistant error: boom" in err + assert "chat not found" in err and "fp agent chats" in err # boxed not-found + assert "isn't configured" in err and "fp agent health" in err + output.configure(no_color=True, quiet=False) + + + +# ── review round: renderers that described the wrong thing ─────────────────── + + +def _history(*gens): + """`(deployment, [(id, version, effect), ...])` → the server's history shape.""" + return [{"deployment": g, "updatedAt": "2026-08-19T13:49:%02dZ" % g, + "policies": [{"id": i, "version": v, "effect": e} for i, v, e in pols]} + for g, pols in gens] + + +def test_history_shows_an_effect_flip_instead_of_no_change(capsys): + """enforce → observe is a policy that STOPPED BLOCKING, and history called + it "no change". + + The row identity was `id@version`, so a generation that changed only the + effect diffed to nothing. Scanning history for "when did this stop + blocking?" is one of the two reasons to read it at all. + """ + _wide_stdout() + output.render_deployment_history("m", _history( + (1, [("guard", 1, "enforce")]), + (2, [("guard", 1, "observe")]), + )) + out = capsys.readouterr().out + assert "no change" not in out + assert "~guard" in out + + +def test_history_shows_a_version_bump_as_one_change(capsys): + """A version bump split into `+guard` and `-guard` on the same row, which + reads as removed-and-re-added rather than moved.""" + _wide_stdout() + output.render_deployment_history("m", _history( + (1, [("guard", 1, "enforce")]), + (2, [("guard", 2, "enforce")]), + )) + # Scoped to generation 2's row: generation 1 is the machine's first, where + # every policy is legitimately a "+". + row = [ln for ln in capsys.readouterr().out.splitlines() if "#2" in ln][0] + assert "~guard" in row + assert "+guard" not in row and "-guard" not in row + + +def test_history_still_reports_plain_adds_and_removes(capsys): + """The `~` case must not have eaten the two it was added beside.""" + _wide_stdout() + output.render_deployment_history("m", _history( + (1, [("a", 1, "enforce")]), + (2, [("a", 1, "enforce"), ("b", 1, "enforce")]), + (3, [("b", 1, "enforce")]), + )) + out = capsys.readouterr().out + assert "+b" in out and "-a" in out + + +def test_history_says_no_change_only_when_nothing_moved(capsys): + """A reissue that lands on an identical set is real, and should still say so.""" + _wide_stdout() + output.render_deployment_history("m", _history( + (1, [("a", 1, "enforce")]), + (2, [("a", 1, "enforce")]), + )) + assert "no change" in capsys.readouterr().out + + +def test_clearing_a_label_is_not_reported_as_renaming_to_blank(capsys): + """`fp fleet rename m ""` clears the override server-side, and the card + said `labelled m as ` — a sentence with a hole in it, describing neither + what was asked nor what happened.""" + _wide_stdout() + output.machine_renamed("m", "") + # Notices go to stderr so stdout stays parseable; the box lands there. + err = capsys.readouterr().err + assert "cleared the label" in err + assert "labelled m as" not in err + + output.machine_renamed("m", "CI runner") + assert "labelled m as CI runner" in capsys.readouterr().err.replace("\n", " ") + + +def test_policy_list_counts_policies_and_captions_versions(capsys): + """The panel said `policies · 4` for three policies, because the endpoint + returns one row per immutable VERSION. The dashboard's own library counts + distinct policies and captions the version total; this now matches it.""" + from fp_cli.models import PolicyVersion + + def pv(pid, version): + return PolicyVersion(id=pid, version=version, description="", sha256="", + source=None, created_at="", created_by=None, + disabled=False, archived=False) + + _wide_stdout() + output.render_policies([pv("a", 1), pv("b", 2), pv("b", 1)]) + out = capsys.readouterr().out + assert "policies · 2 · 3 versions" in out + # newest version of each policy first, rather than server order + b_rows = [ln for ln in out.splitlines() if "b" in ln and "v" in ln + and ("v1" in ln or "v2" in ln)] + assert [("v2" in r) for r in b_rows] == [True, False], b_rows + + +def test_policy_list_omits_the_caption_when_each_policy_has_one_version(capsys): + from fp_cli.models import PolicyVersion + + _wide_stdout() + output.render_policies([PolicyVersion(id="a", version=1, description="", sha256="", + source=None, created_at="", created_by=None, + disabled=False, archived=False)]) + out = capsys.readouterr().out + assert "policies · 1" in out and "versions" not in out diff --git a/fp-cli/tests/test_policy_check.py b/fp-cli/tests/test_policy_check.py new file mode 100644 index 000000000..9950e41c9 --- /dev/null +++ b/fp-cli/tests/test_policy_check.py @@ -0,0 +1,148 @@ +"""Syntax checking and the local policy runner. + +The gap these close: nothing between an author and a fleet parsed policy source. +The CLI rejected a NUL byte, the server checked the id and a size ceiling, and a +file that was not JavaScript at all published, deployed, and failed on the +machine at enforcement time. + +Every test here needs `node`, so each skips without it rather than failing — +node is a real dependency of the check but deliberately not of the CLI. +""" +from __future__ import annotations + +import pytest + +from fp_cli.policy_check import check_syntax, node_available, run_policy + +needs_node = pytest.mark.skipif(not node_available(), reason="node is not on PATH") + +VALID = '''import { customPolicies, allow, deny, instruct } from "failproofai"; +customPolicies.add({ + name: "t", description: "d", match: { events: ["PreToolUse"] }, + fn: async (ctx) => { + const cmd = String(ctx.toolInput?.command ?? ""); + if (cmd.includes("force")) return deny("nope"); + if (cmd.includes("apply")) return instruct("add a note"); + return allow(); + }, +});''' + + +# ── syntax ─────────────────────────────────────────────────────────────────── + + +@needs_node +def test_a_real_policy_parses(): + r = check_syntax(VALID) + assert r.ok and r.checked and r.message == "" + + +@needs_node +@pytest.mark.parametrize("src,label", [ + ("this is not javascript at all {{{", "prose"), + ("export default { name: 'x'", "unclosed brace"), + ("const x = ;", "bad expression"), + ("# python comment\nprint('hi')", "python"), + ("function f( {", "unclosed paren"), +]) +def test_broken_source_is_caught(src, label): + r = check_syntax(src) + assert r.ok is False and r.checked is True, label + assert r.message, "a refusal with no explanation is not a refusal" + + +@needs_node +def test_esm_import_syntax_is_accepted(): + """Policies are ESM. Checking them as a script would reject every real one.""" + assert check_syntax('import { deny } from "failproofai";\nexport const x = 1;').ok + + +@needs_node +def test_top_level_await_is_valid_in_a_module(): + assert check_syntax('const x = await Promise.resolve(1);\nexport default x;').ok + + +@needs_node +def test_the_error_keeps_the_caret_and_drops_nodes_own_stack(): + """The caret is the useful part; node's internal frames and version banner + are node talking about itself inside an error about the user's policy.""" + msg = check_syntax("this is not javascript {{{").message + assert "^" in msg + assert "node:internal" not in msg and "Node.js v" not in msg + + +def test_a_missing_node_is_reported_as_unchecked_not_as_passing(monkeypatch): + """"we did not look" must never render as "we looked and it passed".""" + monkeypatch.setattr("fp_cli.policy_check.node_available", lambda: False) + r = check_syntax("anything at all") + assert r.ok is True and r.checked is False and "node" in r.message + + +# ── running ────────────────────────────────────────────────────────────────── + + +@needs_node +@pytest.mark.parametrize("cmd,expected", [ + ("git push --force origin main", "deny"), + ("kubectl apply -f x.yaml", "instruct"), + ("git status", "allow"), +]) +def test_the_policy_decides_per_input(cmd, expected): + run = run_policy(VALID, tool="Bash", command=cmd) + assert run.ok and run.decision == expected + + +@needs_node +def test_the_bare_failproofai_import_resolves(): + """The file under test is byte-identical to the one that gets published, so + its bare specifier has to resolve the way node resolves it in production.""" + assert run_policy(VALID, command="git status").ok + + +@needs_node +def test_the_strictest_decision_wins(): + """One refusal is a refusal regardless of what the other policies said.""" + two = VALID + ''' +customPolicies.add({ name: "always-allow", match: { events: ["PreToolUse"] }, + fn: async () => allow() });''' + run = run_policy(two, command="git push --force x") + assert run.decision == "deny" and len(run.results) == 2 + + +@needs_node +def test_a_file_registering_nothing_says_so(): + """An empty result is not an allow — it means the file never called add().""" + run = run_policy('export const x = 1;') + assert run.ok is False and "registered no policies" in run.error + + +@needs_node +def test_a_policy_that_throws_is_reported_per_policy_not_as_a_crash(): + boom = '''import { customPolicies } from "failproofai"; +customPolicies.add({ name: "boom", fn: async () => { throw new Error("kaboom"); } });''' + run = run_policy(boom, command="x") + assert run.ok and "kaboom" in run.results[0]["error"] + + +@needs_node +def test_an_infinite_loop_times_out_instead_of_hanging(): + """A policy that cannot decide in five seconds cannot sit on a hook either.""" + spin = '''import { customPolicies } from "failproofai"; +customPolicies.add({ name: "spin", fn: async () => { while (true) {} } });''' + run = run_policy(spin, command="x") + assert run.ok is False and "finish" in run.error + + +@needs_node +def test_file_path_inputs_reach_the_policy(): + src = '''import { customPolicies, allow, deny } from "failproofai"; +customPolicies.add({ name: "env", fn: async (ctx) => + /\\.env/.test(String(ctx.toolInput?.file_path ?? "")) ? deny("no") : allow() });''' + assert run_policy(src, tool="Write", file_path=".env").decision == "deny" + assert run_policy(src, tool="Write", file_path="README.md").decision == "allow" + + +def test_running_without_node_fails_loudly(monkeypatch): + monkeypatch.setattr("fp_cli.policy_check.node_available", lambda: False) + run = run_policy(VALID, command="x") + assert run.ok is False and "node" in run.error diff --git a/fp-cli/tests/test_readme_matches_reality.py b/fp-cli/tests/test_readme_matches_reality.py new file mode 100644 index 000000000..ed0e7fa30 --- /dev/null +++ b/fp-cli/tests/test_readme_matches_reality.py @@ -0,0 +1,125 @@ +"""The README ships inside the wheel and IS the PyPI landing page. + +Nothing else checks it, and it had rotted before this file existed: it documented +`fp incidents` (renamed to `issues` long ago) and claimed the dashboard URL was +required with no default (there is one). Both would have been the first thing a new +user read. + +These tests pin the README's factual claims to the code that implements them. +""" + +from __future__ import annotations + +import pathlib +import re + +from typer.main import get_command + +from fp_cli import config, errors +from fp_cli.app import app + +README = pathlib.Path(__file__).resolve().parent.parent / "README.md" + + +def _registered() -> set[str]: + return set(get_command(app).commands) # type: ignore[attr-defined] + + +def _readme() -> str: + return README.read_text(encoding="utf-8") + + +def test_readme_exists_and_is_substantial(): + """Guards every assertion below from passing vacuously on a missing file.""" + assert README.is_file(), f"{README} is missing — it ships in the wheel" + assert len(_readme()) > 2000 + + +def test_every_command_the_readme_documents_actually_exists(): + text = _readme() + documented = set(re.findall(r"^fp ([a-z][a-z-]*)\b", text, re.M)) + documented |= set(re.findall(r"`fp ([a-z][a-z-]*)[ `]", text)) + # Global flags and shell noise are not commands. + documented = {d for d in documented if not d.startswith("-")} + unknown = documented - _registered() + assert not unknown, ( + f"the README documents commands that do not exist: {sorted(unknown)} " + f"(registered: {sorted(_registered())})" + ) + + +def test_every_subcommand_the_readme_documents_actually_exists(): + """The group-level check above anchors on `^fp <group>` and never looks at the + pipe-separated verb lists beside it. `fp audits ... |update|` sat in the README + documenting a verb that does not exist (it is `edit`) and passed CI. + """ + cmd = get_command(app) + text = _readme() + bad = [] + for line in text.split("\n"): + m = re.match(r"^fp ([a-z][a-z-]*) ([a-z][a-z|-]*)", line) + if not m: + continue + group, verbs = m.group(1), m.group(2) + sub = cmd.commands.get(group) # type: ignore[attr-defined] + if sub is None or not hasattr(sub, "commands"): + continue # not a group; the group-level test covers it + real = set(sub.commands) + for verb in verbs.split("|"): + if verb and verb not in real: + bad.append(f"`fp {group} {verb}` (real verbs: {sorted(real)})") + assert not bad, "the README documents subcommands that do not exist:\n " + "\n ".join(bad) + + +def test_the_readme_install_instructions_name_the_distribution_not_the_command(): + """`pip install fp` installs somebody else's package. The dist is `fp-cli`.""" + text = _readme() + bad = re.findall(r"(?:pipx|pip|uv tool) install fp(?![-\w])", text) + assert not bad, f"install instructions must say fp-cli, not fp: {bad}" + + +def test_the_documented_default_base_url_is_the_real_one(): + assert config.DEFAULT_BASE_URL in _readme(), ( + f"README does not mention the real default base URL {config.DEFAULT_BASE_URL}" + ) + + +def test_the_readme_exit_code_table_matches_the_error_classes(): + """The exit codes are a public scripting contract, restated in four places.""" + text = _readme() + table = dict( + (int(code), meaning.strip()) + for code, meaning in re.findall(r"^\|\s*(\d)\s*\|\s*([^|]+)\|", text, re.M) + ) + assert table, "no exit-code table found in the README" + actual = { + e.exit_code + for e in vars(errors).values() + if isinstance(e, type) + and issubclass(e, Exception) + and isinstance(getattr(e, "exit_code", None), int) + } + documented = set(table) + missing = actual - documented + assert not missing, f"exit codes raised by the CLI but undocumented: {sorted(missing)}" + assert 0 in documented, "the README must document exit code 0" + + +def test_the_readme_does_not_link_private_repo_paths(): + """This README is published to PyPI; enterprise-docs/ is a private-repo path.""" + text = _readme() + for needle in ("enterprise-docs/", "agenteye-enterprise/", "github.com/agenteye"): + assert needle not in text, f"private path {needle!r} must not ship in a public README" + + +def test_the_readme_documents_only_env_vars_the_cli_reads(): + """A documented-but-unread env var is worse than an undocumented one: the user + sets it, nothing happens, and there is no error to search for.""" + import fp_cli + + pkg = pathlib.Path(fp_cli.__file__).resolve().parent + source = "\n".join(p.read_text(encoding="utf-8") for p in pkg.rglob("*.py")) + documented = set(re.findall(r"`(FP_[A-Z_]+)`", _readme())) + assert documented, "the README documents no FP_* env vars — did the table move?" + unread = {v for v in documented if f'"{v}"' not in source} + assert not unread, f"README documents env vars the CLI never reads: {sorted(unread)}" diff --git a/fp-cli/tests/test_review_fixes.py b/fp-cli/tests/test_review_fixes.py new file mode 100644 index 000000000..c03cbaf03 --- /dev/null +++ b/fp-cli/tests/test_review_fixes.py @@ -0,0 +1,161 @@ +"""Tests for the review-driven fixes: json-aware error contract, resolver, SQL detail, +NaN-safe JSON, markup-safe tables, settings int parsing, and the org-switch json path.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +from fp_cli import _click_compat as click # the Click Typer is running +import httpx +import pytest +import respx + +from fp_cli import client, output +from fp_cli.app import app +from fp_cli.commands import _write +from fp_cli.errors import NotFoundError + +BASE = "http://dash.test" + + +# --- json-aware error contract (envelope on stdout) ------------------------------ + + +@respx.mock +def test_json_error_envelope_not_found(logged_in, runner): + respx.get(f"{BASE}/api/users").mock(return_value=httpx.Response(200, json=[])) + result = runner.invoke(app, ["--json", "users", "show", "nobody@x.com"]) + assert result.exit_code == 6 + data = json.loads(result.stdout) # must be a clean JSON object on stdout + assert "nobody@x.com" in data["error"] + assert data["exit_code"] == 6 + assert "hint" in data # the "run `fp users list`" hint rides along + + +def test_json_error_envelope_usage(logged_in, runner): + # A client-side validation error is ALSO a JSON envelope on stdout under --json. + result = runner.invoke(app, ["--json", "sessions", "--since", "bogus"]) + assert result.exit_code == 2 + data = json.loads(result.stdout) + assert data["exit_code"] == 2 + assert "since" in data["error"].lower() + + +@respx.mock +def test_json_error_envelope_carries_status_and_request_id(logged_in, runner): + respx.get(f"{BASE}/api/sessions").mock( + return_value=httpx.Response(500, json={"error": "boom"}, headers={"x-request-id": "req-123"}) + ) + result = runner.invoke(app, ["--json", "sessions"]) + assert result.exit_code == 1 + data = json.loads(result.stdout) + assert data["error"] == "boom" and data["exit_code"] == 1 and data["status"] == 500 + assert data["request_id"] == "req-123" # kept for server-log correlation + assert "HTTP" not in data["error"] # clean message; status is a separate field + + +def test_unknown_command_json_envelope_via_env(logged_in, runner): + # An error raised BEFORE the group callback runs (unknown command) still honors --json via the + # env fallback, so an agent that typos a command under --json gets a parseable error on stdout. + result = runner.invoke(app, ["frobnicate"], env={"FP_JSON": "1"}) + assert result.exit_code == 2 + data = json.loads(result.stdout) + assert "frobnicate" in data["error"] and data["exit_code"] == 2 + + +def test_flag_placement_hint(logged_in, runner): + # A global flag AFTER the command nudges toward the right placement. + result = runner.invoke(app, ["sessions", "--json"]) + assert result.exit_code == 2 + assert "before the command" in result.stderr + + +# --- shared resolve_one ---------------------------------------------------------- + + +def test_resolve_one_resolves_by_name_and_id(): + items = [SimpleNamespace(name="a", id="1"), SimpleNamespace(name="b", id="2")] + assert _write.resolve_one(items, "a", kind="key", list_cmd="keys list").id == "1" + assert _write.resolve_one(items, "2", kind="key", list_cmd="keys list").name == "b" + + +def test_resolve_one_not_found_raises_exit_6(): + with pytest.raises(NotFoundError) as exc: + _write.resolve_one([], "zzz", kind="key", list_cmd="keys list") + assert exc.value.exit_code == 6 + assert exc.value.hint # carries a "run `fp keys list`" hint + + +def test_resolve_one_ambiguous_raises_usage(): + dup = [SimpleNamespace(name="a", id="1"), SimpleNamespace(name="a", id="2")] + with pytest.raises(click.UsageError): + _write.resolve_one(dup, "a", kind="key", list_cmd="keys list") + + +# --- client._extract_error folds in the server `detail` -------------------------- + + +def test_extract_error_includes_detail(): + resp = httpx.Response(400, json={"error": "query failed", "detail": "syntax error near 'FORM'"}) + assert client._extract_error(resp) == "query failed: syntax error near 'FORM'" + + +def test_extract_error_without_detail(): + resp = httpx.Response(404, json={"error": "Not found."}) + assert client._extract_error(resp) == "Not found." + + +# --- emit_json is always valid JSON (no NaN/Infinity tokens) --------------------- + + +def test_emit_json_coerces_non_finite(capsys): + output.emit_json({"a": float("nan"), "b": float("inf"), "c": float("-inf"), "d": 1.5}) + data = json.loads(capsys.readouterr().out) # would raise on a bare NaN token + assert data == {"a": None, "b": None, "c": None, "d": 1.5} + + +# --- print_table never raises MarkupError on bracketed cells --------------------- + + +def test_print_table_escapes_markup(capsys): + output.configure(no_color=True) + output.print_table(["k", "v"], [["x", "[/]"], ["y", "[red]bad[/red]"]]) # must not raise + out = capsys.readouterr().out + assert "[/]" in out and "[red]bad[/red]" in out # rendered literally + + +# --- settings set --value coercion never crashes on odd input -------------------- + + +@respx.mock +def test_settings_set_unicode_value_no_crash(logged_in, runner): + respx.get(f"{BASE}/api/settings").mock( + return_value=httpx.Response(200, json={"settings": [ + {"key": "session_ttl_secs", "value": 100, "schema": {"kind": "positive_int"}}]}) + ) + respx.put(f"{BASE}/api/settings/session_ttl_secs").mock( + return_value=httpx.Response(422, json={"error": "must be an integer"}) + ) + result = runner.invoke(app, ["--json", "settings", "set", "session_ttl_secs", "--value", "²"]) + assert "Traceback" not in result.output # the old isdigit()/int() path raised here + assert result.exit_code == 1 + assert json.loads(result.stdout)["error"] == "must be an integer" + + +# --- orgs switch not-found now emits a JSON envelope (was silent under --json) ---- + + +@respx.mock +def test_orgs_switch_not_found_json_emits_error(logged_in, runner): + respx.get(f"{BASE}/api/auth/session").mock(return_value=httpx.Response(200, json={ + "id": "u1", "email": "me@test", "is_instance_admin": False, + "memberships": [{"org_id": "o1", "org_slug": "acme", "org_name": "Acme", + "permissions": ["events:read"], "permission_set": "standard"}], + })) + result = runner.invoke(app, ["--json", "orgs", "switch", "acm"]) + assert result.exit_code == 2 + data = json.loads(result.stdout) + assert "no org named acm" in data["error"] + assert data["exit_code"] == 2 + assert "acme" in data.get("hint", "") diff --git a/fp-cli/tests/test_telemetry_completeness.py b/fp-cli/tests/test_telemetry_completeness.py new file mode 100644 index 000000000..fa913e6a5 --- /dev/null +++ b/fp-cli/tests/test_telemetry_completeness.py @@ -0,0 +1,90 @@ +"""Anti-drift guard: every command and flag must be tracked by telemetry. + +Introspects the assembled Typer/Click app and asserts the derived telemetry catalog +covers every command, every leaf subcommand, and every option token. This fails the +moment a new command or flag is added without being trackable — so usage signal can +never silently go missing and a flag VALUE can never leak (a flag must be allowlisted +by name to be emitted at all). +""" + +from __future__ import annotations + +import pytest +from typer.main import get_command + +from fp_cli import _click_compat as click # the Click Typer is running +from fp_cli import analytics +from fp_cli import analytics_registry as reg +from fp_cli.app import app + + +def _leaf_commands(): + """Yield (path_tuple, click.Command) for every leaf command in the app.""" + cli = get_command(app) + + def walk(cmd, prefix): + subs = getattr(cmd, "commands", None) + if subs: + for name, sub in subs.items(): + yield from walk(sub, prefix + (name,)) + else: + yield prefix, cmd + + yield from walk(cli, ()) + + +def _all_option_tokens(): + cli = get_command(app) + tokens = set() + + def walk(cmd): + for p in getattr(cmd, "params", []): + if click.is_option(p): + tokens.update(p.opts) + tokens.update(p.secondary_opts) + for sub in (getattr(cmd, "commands", None) or {}).values(): + walk(sub) + + walk(cli) + return tokens + + +def test_every_command_is_known(): + known, leaves, _flags, _vf = reg.build() + missing = [] + for path, _cmd in _leaf_commands(): + group = path[0] + if group not in known: + missing.append(group) + if len(path) >= 2 and path[1] not in leaves.get(group, frozenset()): + missing.append(" ".join(path)) + assert not missing, f"commands not tracked by telemetry catalog: {sorted(set(missing))}" + + +def test_every_option_is_tracked(): + _known, _leaves, flags, _vf = reg.build() + untracked = sorted(t for t in _all_option_tokens() if t not in flags) + assert not untracked, f"option flags missing from telemetry catalog: {untracked}" + + +def test_resolve_command_path_for_nested_groups(): + # A nested invocation resolves to (group, leaf) using only static catalog names. + assert analytics._resolve_command_path(["--json", "orgs", "list"]) == ("orgs", "list") + assert analytics._resolve_command_path(["orgs", "switch", "acme"]) == ("orgs", "switch") + assert analytics._resolve_command_path(["--org", "acme", "agent", "show", "s1"]) == ("agent", "show") + assert analytics._resolve_command_path(["whoami"]) == ("whoami", None) + + +def test_sanitize_flags_never_emits_values_for_any_command(): + # For every leaf command, feed its own option tokens with fake values and assert + # the sanitised output contains only known flag names (never the values). + _known, _leaves, flags, _vf = reg.build() + for path, cmd in _leaf_commands(): + argv = list(path) + for p in getattr(cmd, "params", []): + if click.is_option(p) and p.opts: + argv += [p.opts[0], "SECRET-VALUE"] + emitted = analytics._sanitize_flags(argv) + assert "SECRET-VALUE" not in emitted + for f in emitted: + assert f in set(flags.values()), f"{f} not a canonical flag name" diff --git a/fp-cli/tests/test_usage.py b/fp-cli/tests/test_usage.py new file mode 100644 index 000000000..f870102a3 --- /dev/null +++ b/fp-cli/tests/test_usage.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import json + +import httpx +import respx + +from fp_cli.app import app + +BASE = "http://dash.test" + +USAGE = { + "org_id": "00000000-0000-0000-0000-000000000001", + "billing_anchor": "2026-07-15T00:00:00Z", + "window": { + "start": "2026-07-15T00:00:00Z", + "end": "2026-08-14T00:00:00Z", + "current": True, + }, + "usage": { + "events_ingested": 12840, + "sessions": 146, + "agents": 12, + "environments": 3, + "evaluation_runs": 84, + "evaluation_finishes": 79, + "evaluations": 420, + "metrics": 1260, + "queries_created": 31, + "dashboards_created": 6, + "alerts_created": 11, + "issues_created": 34, + "audit_runs": 18, + "audit_finishes": 16, + "keys_created": 5, + "keys_active": 3, + "users_created": 5, + "users_active": 1, + }, + "calculated_at": "2026-07-31T12:00:00Z", + "stale_after": "2026-07-31T12:01:00Z", +} + + +@respx.mock +def test_usage_json_returns_dashboard_contract_unchanged(logged_in, runner): + request = respx.get(f"{BASE}/api/usage").mock( + return_value=httpx.Response(200, json=USAGE) + ) + + result = runner.invoke(app, ["--json", "usage"]) + + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == USAGE + assert request.called + + +@respx.mock +def test_usage_human_output_is_grouped_and_readable(logged_in, runner): + respx.get(f"{BASE}/api/usage").mock(return_value=httpx.Response(200, json=USAGE)) + + result = runner.invoke(app, ["--no-color", "usage"]) + + assert result.exit_code == 0, result.output + assert "Jul 15, 2026" in result.stdout + assert "12,840" in result.stdout + assert "PIPELINE COMPLETION" in result.stdout + assert "WORKSPACE & ACCESS" in result.stdout + assert "94%" in result.stdout + + +@respx.mock +def test_usage_names_required_permission(logged_in, runner): + respx.get(f"{BASE}/api/usage").mock( + return_value=httpx.Response( + 403, + json={"error": "forbidden", "required_permission": "usage:read"}, + ) + ) + + result = runner.invoke(app, ["--json", "usage"]) + + assert result.exit_code == 5 + assert json.loads(result.stdout)["error"] == "you don't have the usage:read permission" + + +@respx.mock +def test_usage_reports_missing_billing_date(logged_in, runner): + respx.get(f"{BASE}/api/usage").mock( + return_value=httpx.Response(404, json={"error": "billing date is not set"}) + ) + + result = runner.invoke(app, ["--json", "usage"]) + + assert result.exit_code == 6 + assert json.loads(result.stdout)["error"] == "billing date is not set" + + +@respx.mock +def test_usage_rejects_non_object_success_response(logged_in, runner): + respx.get(f"{BASE}/api/usage").mock(return_value=httpx.Response(200, json=[])) + + result = runner.invoke(app, ["--json", "usage"]) + + assert result.exit_code == 1 + assert json.loads(result.stdout)["error"] == "The dashboard returned an invalid usage response." + + +def test_usage_is_one_command_with_help_but_no_subcommands(home, runner): + help_result = runner.invoke(app, ["usage", "--help"]) + assert help_result.exit_code == 0 + assert "current fixed 30-day metering window" in help_result.stdout + + child_result = runner.invoke(app, ["usage", "history"]) + assert child_result.exit_code == 2 diff --git a/fp-cli/tests/test_v1_origin_diagnostic.py b/fp-cli/tests/test_v1_origin_diagnostic.py new file mode 100644 index 000000000..8b7a9942b --- /dev/null +++ b/fp-cli/tests/test_v1_origin_diagnostic.py @@ -0,0 +1,70 @@ +"""A 404 in key mode has two very different causes; the CLI must tell them apart. + +Pointing `--base-url` at a dashboard whose front door does not forward `/v1` is +the likeliest first-run mistake. It used to arrive as a 3xx to `/login`, which +`_raise_for_status` names explicitly — but a dashboard that correctly declines to +auth-gate `/v1` (see `dashboard/proxy.ts`) returns its own 404 instead, and on +the status code alone that is indistinguishable from "no such record". + +The tell is the content type: the API only ever answers JSON. +""" + +import httpx +import pytest +import respx + +from fp_cli import client as api +from fp_cli.client import AuthMode, ClientContext +from fp_cli.errors import ApiError, NotFoundError + +BASE = "http://server.test" + + +def key_ctx() -> ClientContext: + return ClientContext(base_url=BASE, api_key="ak_test", auth_mode=AuthMode.API_KEY) + + +@respx.mock +def test_html_404_names_the_routing_problem() -> None: + respx.get(f"{BASE}/v1/events").mock( + return_value=httpx.Response( + 404, + text="<!DOCTYPE html><html>404</html>", + headers={"content-type": "text/html; charset=utf-8"}, + ) + ) + with pytest.raises(ApiError) as excinfo: + api.list_events(key_ctx()) + assert "not routed" in str(excinfo.value) + # Must NOT degrade to the ordinary not-found error, which would send the + # reader hunting for a missing record instead of a misconfigured base URL. + assert not isinstance(excinfo.value, NotFoundError) + + +@respx.mock +def test_json_404_is_still_an_ordinary_not_found() -> None: + """The other half. + + Without this, the guard above could 'pass' by relabelling every 404 as a + routing problem — worse than the bug it fixes, because then a genuinely + missing record would send people to check their base URL forever. + """ + respx.get(f"{BASE}/v1/events").mock( + return_value=httpx.Response(404, json={"error": "nope"}) + ) + with pytest.raises(NotFoundError): + api.list_events(key_ctx()) + + +@respx.mock +def test_session_mode_html_404_is_untouched() -> None: + """Cookie mode keeps its existing behaviour — this diagnostic is key-mode only.""" + respx.get(f"{BASE}/api/events").mock( + return_value=httpx.Response( + 404, + text="<!DOCTYPE html><html>404</html>", + headers={"content-type": "text/html; charset=utf-8"}, + ) + ) + with pytest.raises(NotFoundError): + api.list_events(ClientContext(base_url=BASE, token="tok")) diff --git a/fp-cli/tests/test_v1_routing.py b/fp-cli/tests/test_v1_routing.py new file mode 100644 index 000000000..494228cd3 --- /dev/null +++ b/fp-cli/tests/test_v1_routing.py @@ -0,0 +1,214 @@ +"""Anti-drift guard for API-key mode's `/api/*` -> `/v1/*` translation. + +Why this test is shaped the way it is +------------------------------------- +The CLI already had an anti-drift test that stayed green through an 85 -> 0 collapse +(`memory/2026-07-24-typer-026-vendored-click-breaks-cli-errors.md`): it walked the +command tree with the *same* broken predicate the production code used, so it +compared nothing to nothing and reported success. The lesson is not "write an +anti-drift test", it is "get the expectation from a source the production code does +not also consult". + +So each leg below sources its input independently of `client.py`'s own logic: + +1. The set of paths comes from an **AST scan of client.py's string literals**, not + from any registry the translator reads. Add a call site and it appears here + whether or not anyone remembered a list. +2. The unsupported commands are driven through a **real CliRunner**, asserting the + exit code AND that zero HTTP calls happened — the "fails before any network + call" half is the part a return-value assertion cannot see. + +A third leg used to check every translated `/v1` path against the server router's +own `.route()` literals, read out of an AgentEye checkout. The server now lives in +a separate private repository, so that leg could only ever run when a checkout +happened to be on disk and skipped everywhere else — including all of CI, where a +skip reads as green. It was removed rather than left switched off. Nothing here +verifies that a translated path is a route the server actually registers; a rename +on the server side surfaces as a 404 at runtime. +""" + +from __future__ import annotations + +import ast +import pathlib + +import httpx +import pytest +import respx + +from fp_cli import client as api +from fp_cli.app import app +from fp_cli.errors import KeyModeUnsupportedError + +CLIENT_PY = pathlib.Path(api.__file__) +BASE = "http://dash.test" +KEY = "ak_live_abc123" + + +# --- leg 1: every /api/ literal in client.py is classified ------------------- + + +def _api_path_literals(source: str) -> set: + """Every `/api/...` path the CLI can build, read straight out of the source. + + f-strings are reconstructed as templates (`f"/api/keys/{key_id}/disable"` -> + `/api/keys/{}/disable`) so an interpolated id becomes a wildcard segment rather + than a fragment. The constant *pieces* of an f-string are skipped for exactly that + reason: `"/api/issues/"` on its own is not a path anyone requests. + """ + tree = ast.parse(source) + inside_fstring = { + id(v) + for node in ast.walk(tree) + if isinstance(node, ast.JoinedStr) + for v in node.values + } + found = set() + for node in ast.walk(tree): + if isinstance(node, ast.JoinedStr): + text = "".join( + v.value if isinstance(v, ast.Constant) and isinstance(v.value, str) else "{}" + for v in node.values + ) + elif ( + isinstance(node, ast.Constant) + and isinstance(node.value, str) + and id(node) not in inside_fstring + ): + text = node.value + else: + continue + # `> len("/api/")` skips the `_API_PREFIX` constant itself: it is the rule, not + # a path, and no real request is ever made to a bare "/api/". + if text.startswith("/api/") and len(text) > len("/api/"): + found.add(text) + return found + + +def _buckets(path: str) -> list: + """Which classification(s) `path` falls into, computed from the DATA in client.py + rather than by calling its classifier — a catch-all `else` in the translator must + not be able to make this test pass.""" + kinds = [] + if path in api._V1_RENAMED: + kinds.append("override") + else: + # The rename is an exact-match override of the family rule, so it is only + # checked when the path is not itself renamed; that keeps the three buckets + # genuinely disjoint instead of "exactly one, if you squint". + family = path[len("/api/") :].split("/", 1)[0] + if family in api._V1_NO_EQUIVALENT: + kinds.append("no-v1") + if family in api._V1_MECHANICAL_FAMILIES: + kinds.append("mechanical") + return kinds + + +def test_every_api_literal_is_classified_exactly_once(): + literals = _api_path_literals(CLIENT_PY.read_text()) + assert literals, "the AST scan found no /api/ paths — it has stopped asking anything" + unclassified = sorted(p for p in literals if not _buckets(p)) + assert not unclassified, ( + "these /api/ paths have no /v1 classification — add the family to " + "_V1_MECHANICAL_FAMILIES, _V1_RENAMED or _V1_NO_EQUIVALENT in client.py " + f"(and check the server actually serves it): {unclassified}" + ) + ambiguous = sorted(p for p in literals if len(_buckets(p)) > 1) + assert not ambiguous, f"/api paths matching more than one bucket: {ambiguous}" + + +def test_no_v1_paths_raise_instead_of_being_requested(): + literals = _api_path_literals(CLIENT_PY.read_text()) + excluded = [p for p in literals if _buckets(p) == ["no-v1"]] + assert excluded, "the excluded families vanished from client.py — verify on purpose" + for path in excluded: + with pytest.raises(KeyModeUnsupportedError): + api._v1_path(path) + + +def test_an_unknown_family_raises_loudly(): + # The property the two lists above cannot prove about each other: a path in no + # bucket must fail, not pass through to a URL nobody chose. + with pytest.raises(Exception) as excinfo: + api._v1_path("/api/telepathy/read") + assert "/api/telepathy/read" in str(excinfo.value) + + +def test_the_score_keys_rename_is_applied(): + # Hyphen -> underscore. It exists only in the dashboard proxy + # (dashboard/app/api/evaluations/score-keys/route.ts), so a blind s|^/api|/v1| + # 404s and the CLI reports a cheerful "Not found." + assert api._v1_path("/api/evaluations/score-keys") == "/v1/evaluations/score_keys" + assert api._FACET_PATHS["score_filters"] == "/api/evaluations/score-keys" + + +# --- leg 2: unsupported commands fail before any network call ----------------- + + +UNSUPPORTED = [ + ["login"], + ["logout"], + ["orgs", "list"], + ["orgs", "switch", "acme"], + ["orgs", "current"], + ["orgs", "perms"], + ["agent", "health"], + ["agent", "models"], + ["agent", "chats"], + ["agent", "ask", "hello"], + ["agent", "show", "abc123"], + ["agent", "rename", "abc123", "--title", "x"], + ["agent", "delete", "abc123", "--yes"], + ["keys", "update", "ci-bot", "--add", "keys:read"], +] + + +@pytest.mark.parametrize("argv", UNSUPPORTED, ids=lambda a: " ".join(a[:2])) +def test_unsupported_command_exits_2_with_zero_http_calls(logged_in, runner, argv): + # `logged_in` seeds a saved session deliberately: the failure must come from the + # key, not from having nothing else to fall back on. + with respx.mock(assert_all_called=False) as mock: + catch_all = mock.route().mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["--base-url", BASE, "--api-key", KEY, *argv]) + assert result.exit_code == 2, f"{argv} -> {result.exit_code}: {result.output}" + assert catch_all.call_count == 0, f"{argv} opened a connection before failing" + + +def test_unsupported_command_says_why_and_what_to_do(logged_in, runner): + result = runner.invoke(app, ["--base-url", BASE, "--api-key", KEY, "--json", "orgs", "list"]) + assert result.exit_code == 2, result.output + assert "API key" in result.stdout + assert "fp login" in result.stdout # the hint rides in the JSON envelope + + +def test_whoami_is_the_exception(logged_in, runner): + # Contractually "never errors" (cli/skill/SKILL.md leans on it as the pre-flight). + result = runner.invoke(app, ["--base-url", BASE, "--api-key", KEY, "whoami"]) + assert result.exit_code == 0, result.output + + +SUPPORTED = [ + (["keys", "list"], "/v1/keys", []), + (["sessions"], "/v1/sessions", {"sessions": [], "next_cursor": None}), + (["events"], "/v1/events/summary", {"events": [], "next_cursor": None}), + (["issues", "list"], "/v1/issues", []), + (["list", "envs"], "/v1/events/environments", []), + (["list", "score_filters"], "/v1/evaluations/score_keys", []), + (["query", "list"], "/v1/queries", {"queries": []}), + (["users", "list"], "/v1/users", []), + (["settings", "list"], "/v1/settings", {"settings": []}), + (["alerts", "list"], "/v1/alerts", []), + (["audits", "list"], "/v1/audits", []), +] + + +@pytest.mark.parametrize("argv,path,body", SUPPORTED, ids=lambda a: " ".join(a[:2]) if isinstance(a, list) else "") +def test_supported_commands_reach_their_v1_route(logged_in, runner, argv, path, body): + """The other half of leg 3: the guard must not have swallowed the working set, and + each command must land on the `/v1` route it is supposed to (only this exact URL is + mocked, so a wrong path is a connection error, not a quiet pass).""" + with respx.mock(assert_all_called=False) as mock: + route = mock.get(f"{BASE}{path}").mock(return_value=httpx.Response(200, json=body)) + result = runner.invoke(app, ["--base-url", BASE, "--api-key", KEY, "--json", *argv]) + assert result.exit_code == 0, f"{argv} -> {result.exit_code}: {result.output}" + assert route.called diff --git a/fp-cli/tests/test_whoami.py b/fp-cli/tests/test_whoami.py new file mode 100644 index 000000000..b03e354fc --- /dev/null +++ b/fp-cli/tests/test_whoami.py @@ -0,0 +1,104 @@ +"""`whoami` (logged in): the identity header + permissions/orgs panels, and the +permission grouping/coloring logic.""" + +from __future__ import annotations + +import httpx +import respx + +from fp_cli import theme +from fp_cli.app import app +from fp_cli.output import _group_permissions + +BASE = "http://dash.test" + + +# --- permission grouping / coloring ----------------------------------------- + + +def test_group_permissions_orders_resources_and_actions_by_risk(): + grouped = _group_permissions( + ["keys:delete", "keys:read", "keys:create", "dashboards:read", "agent:use"] + ) + # resources follow the fixed priority order (dashboards < keys < agent) + assert [r for r, _ in grouped] == ["dashboards", "keys", "agent"] + by_res = dict(grouped) + # actions within a row ordered by risk: read → create → delete + assert [a for a, _ in by_res["keys"]] == ["read", "create", "delete"] + colors = dict(by_res["keys"]) + assert colors["read"] == theme.PERM_READ + assert colors["create"] == theme.PERM_WRITE + assert colors["delete"] == theme.PERM_DANGER + + +def test_group_permissions_unknown_action_falls_back_neutral(): + grouped = _group_permissions(["weird:frobnicate"]) + assert grouped == [("weird", [("frobnicate", theme.DEFAULT_PERM_COLOR)])] + + +# --- human render ------------------------------------------------------------ + + +def _session(memberships): + return { + "id": "62230791-4811-4f04-b388-ae57bdcb422e", + "email": "admin@local.host", + "is_instance_admin": True, + "memberships": memberships, + } + + +@respx.mock +def test_whoami_human_renders_header_and_panels(logged_in, runner): + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([ + {"org_id": "o1", "org_slug": "globex", "org_name": "Globex Corp", "permission_set": "admin", + "permissions": ["dashboards:read", "dashboards:write", "dashboards:delete", "keys:read", "agent:use"]}, + {"org_id": "o2", "org_slug": "acme", "org_name": "Acme Corp", "permission_set": "admin", + "permissions": ["events:read"]}, + ])) + ) + result = runner.invoke(app, ["--org", "globex", "whoami"]) + assert result.exit_code == 0, result.output + out = result.stdout + # identity header: email, instance role, FULL user id, active org + assert "admin@local.host" in out + assert "62230791-4811-4f04-b388-ae57bdcb422e" in out + assert "instance admin" in out + # permissions panel + assert "permissions" in out + assert "dashboards" in out and "read" in out and "write" in out and "delete" in out + # orgs panel + switch hint to the non-active org + assert "your orgs" in out + assert "globex" in out and "acme" in out + assert "Globex Corp" in out # permissions title shows the active org NAME + assert "fp orgs switch <slug>" in out + + +@respx.mock +def test_whoami_single_org_has_no_switch_hint(logged_in, runner): + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([ + {"org_id": "o", "org_slug": "acme", "org_name": "Acme", "permission_set": "admin", + "permissions": ["events:read"]}, + ])) + ) + result = runner.invoke(app, ["whoami"]) + assert result.exit_code == 0, result.output + assert "your orgs" in result.stdout + assert "orgs switch" not in result.stdout # only one org → no switch hint + + +@respx.mock +def test_whoami_no_color_marks_destructive_actions(logged_in, runner): + respx.get(f"{BASE}/api/auth/session").mock( + return_value=httpx.Response(200, json=_session([ + {"org_id": "o", "org_slug": "acme", "org_name": "Acme", "permission_set": "admin", + "permissions": ["keys:read", "keys:delete", "keys:disable"]}, + ])) + ) + result = runner.invoke(app, ["whoami"], env={"NO_COLOR": "1"}) + assert result.exit_code == 0, result.output + out = result.stdout + assert "delete*" in out and "disable*" in out # destructive marked in plain text + assert "read" in out and "read*" not in out # non-destructive unmarked diff --git a/fp-cli/uv.lock b/fp-cli/uv.lock new file mode 100644 index 000000000..a7101efb8 --- /dev/null +++ b/fp-cli/uv.lock @@ -0,0 +1,552 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa", size = 369072, upload-time = "2026-08-15T08:16:22.964Z" }, + { url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda", size = 251142, upload-time = "2026-08-15T08:16:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45", size = 240714, upload-time = "2026-08-15T08:16:26.392Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab", size = 279637, upload-time = "2026-08-15T08:16:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a", size = 276543, upload-time = "2026-08-15T08:16:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3", size = 261644, upload-time = "2026-08-15T08:16:30.722Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb", size = 259609, upload-time = "2026-08-15T08:16:32.248Z" }, + { url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f", size = 252457, upload-time = "2026-08-15T08:16:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea", size = 242240, upload-time = "2026-08-15T08:16:35.396Z" }, + { url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f", size = 280308, upload-time = "2026-08-15T08:16:36.825Z" }, + { url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182", size = 258679, upload-time = "2026-08-15T08:16:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa", size = 277221, upload-time = "2026-08-15T08:16:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818", size = 263799, upload-time = "2026-08-15T08:16:40.885Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20", size = 182037, upload-time = "2026-08-15T08:16:42.198Z" }, + { url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d", size = 206030, upload-time = "2026-08-15T08:16:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5", size = 185092, upload-time = "2026-08-15T08:16:45.177Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" }, + { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" }, + { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" }, + { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[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 = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fp-cli" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "httpx" }, + { name = "posthog" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typer" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "respx" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "posthog", specifier = ">=3.5,<8" }, + { name = "pygments", specifier = ">=2.13" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7" }, + { name = "respx", marker = "extra == 'dev'", specifier = ">=0.21" }, + { name = "rich", specifier = ">=13" }, + { name = "typer", specifier = ">=0.12,<0.28" }, +] +provides-extras = ["dev"] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, 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 = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[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 = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "posthog" +version = "7.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "distro" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/77/3737f60571995ba07677b058bb1523b7c26f28570806b8ffaf83a66df18c/posthog-7.39.1.tar.gz", hash = "sha256:0d184596e35057457fc1094883646fd23de2d6338db8b9c3ea770643fb55d8a2", size = 428586, upload-time = "2026-08-14T13:50:24.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/79/ee5c01937bfb0c80415929e25aa1e8296c48e26fc9a10fe1d9f665f0f478/posthog-7.39.1-py3-none-any.whl", hash = "sha256:e76e82fe571314a0a9bc11d039fd1a1a8d210cd0f737899d41b31f61b73bf08c", size = 504259, upload-time = "2026-08-14T13:50:22.896Z" }, +] + +[[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.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "respx" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, +] + +[[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 = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typer" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/40/4a3db7990d1f62a53182aa96eaef57aeb2886a27f90a195bc66713565d31/typer-0.27.1.tar.gz", hash = "sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df", size = 203994, upload-time = "2026-08-03T14:41:03.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/89/9518bc0c3929bee36b3a4a8e3daddd6e03f92f9961c66d4983b837160543/typer-0.27.1-py3-none-any.whl", hash = "sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56", size = 122874, upload-time = "2026-08-03T14:41:04.391Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] diff --git a/osv-scanner.toml b/osv-scanner.toml index e03912a61..e0fdb44c9 100644 --- a/osv-scanner.toml +++ b/osv-scanner.toml @@ -14,4 +14,17 @@ # ignoreUntil = 2026-07-01 # reason = "No upstream fix yet; transitive via <pkg>; not reachable in our usage. Re-review by 2026-07-01." # -# There are currently no ignored vulnerabilities — the dependency tree is clean. +# Both identifiers for the same advisory. The scanner matches `id` against the +# record's PRIMARY id, which for a PyPI advisory is the PYSEC one — ignoring only +# the GHSA alias loaded cleanly, changed nothing, and left the gate red. +[[IgnoredVulns]] +id = "PYSEC-2026-311" +ignoreUntil = 2026-11-20 +reason = "Alias of GHSA-f4j7-r4q5-qw2c; see the entry below for the full justification. Re-review by 2026-11-20." + +[[IgnoredVulns]] +id = "GHSA-f4j7-r4q5-qw2c" +ignoreUntil = 2026-11-20 +reason = """ +chromadb 1.1.1, transitive via crewai, in sdk/python/uv.lock. No fixed version exists — OSV reports "0 vulnerabilities can be fixed" and an empty FIXED VERSION — so there is nothing to bump to. It is not reachable from anything we ship: failproofai-sdk declares NO unconditional dependencies (every Requires-Dist in the built wheel is gated behind an extra, and CI installs it with --no-deps), so `pip install failproofai-sdk` never brings chromadb. It arrives only via `failproofai-sdk[crewai]`, which installs CrewAI — and anyone installing CrewAI has chromadb from CrewAI regardless of us. sdk/python/uv.lock is the dev/test lockfile that pins every extra so CI can exercise the adapters; it is not a published artifact. The lockfile stays in the scan on purpose, so a FIXABLE finding here still blocks. Re-review by 2026-11-20, by which point either chromadb has a patched release or crewai has moved off it. +""" diff --git a/sdk/python/.gitignore b/sdk/python/.gitignore new file mode 100644 index 000000000..dd29e29b8 --- /dev/null +++ b/sdk/python/.gitignore @@ -0,0 +1,24 @@ +# Python +__pycache__/ +*.py[cod] +*.egg +*.egg-info/ +dist/ +build/ + +# Virtual environments +.venv/ +venv/ + +# Testing & coverage +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store diff --git a/sdk/python/LICENSE b/sdk/python/LICENSE new file mode 100644 index 000000000..9802e634b --- /dev/null +++ b/sdk/python/LICENSE @@ -0,0 +1,42 @@ +MIT License + +Copyright (c) 2025 ExosphereHost Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Commons Clause License Condition v1.0 + +The Software is provided to you by the Licensor under the License, as defined +below, subject to the following condition. + +Without limiting other conditions in the License, the grant of rights under +the License will not include, and the License does not grant to you, the right +to Sell the Software. + +For purposes of the foregoing, "Sell" means practicing any or all of the +rights granted to you under the License to provide to third parties, for a +fee or other consideration (including without limitation fees for hosting or +consulting/support services related to the Software), a product or service +whose value derives, entirely or substantially, from the functionality of the +Software. Any license notice or attribution required by the License must also +include this Commons Clause License Condition notice. + +Software: failproofai +License: MIT +Licensor: ExosphereHost Inc diff --git a/sdk/python/README.md b/sdk/python/README.md new file mode 100644 index 000000000..a4d251465 --- /dev/null +++ b/sdk/python/README.md @@ -0,0 +1,450 @@ +# failproofai-sdk + +The Python SDK for [Failproof AI](https://befailproof.ai) agent observability. It +records what your agent did — tool calls, model requests, hooks, errors, waits for +a human — as structured events, and hands them to the daemon that ships them to +the platform. + +- **PyPI distribution:** `failproofai-sdk` +- **Import name:** `failproofai_sdk` +- **Dependencies:** none. Standard library only, so installing it constrains + nothing else in your environment. + +## Installation + +```bash +pip install failproofai-sdk +# or +uv add failproofai-sdk +``` + +> **Do not `pip install agenteye`.** That distribution name is occupied on PyPI by +> a stranded build of an old CLI, which ships a module called `agenteye_cli` and is +> not this SDK. Installing it gives you `ModuleNotFoundError` at best, and — if +> this SDK is already present — pip treats it as an upgrade and **removes the SDK** +> to install the CLI. The import that worked five minutes ago then stops working. + +## How events reach the platform + +The SDK never opens a network connection. It appends events to an in-memory queue, +and a background thread writes them to local JSONL batches: + +``` +your agent → failproofai_sdk → ~/.failproofai/custom-agents/events/*.jsonl → daemon → platform +``` + +A daemon on the same host watches that directory and uploads each batch. Either +`failproofaid` or the older `agenteye-collector` will do; both read the default +spool root. If no daemon is running, batches simply accumulate on disk — the SDK +does not fail, and your agent does not block. + +## Agent frameworks + +If your agent runs on LangChain/LangGraph, CrewAI, LlamaIndex or Pydantic AI, +one line captures it — runs, sub-agents, tools, model calls and their token +counts — without threading an id through anything: + +```python +import failproofai_sdk + +failproofai_sdk.configure(environment="production") +failproofai_sdk.instrument() # auto-detects what is already imported + +graph.invoke({"messages": [...]}) # unchanged +``` + +```bash +pip install 'failproofai-sdk[langgraph]' # or [langchain] [crewai] [llamaindex] [pydantic-ai] +``` + +The adapter code ships in the base wheel and imports its framework lazily, so +the extras are a convenience — `pip install failproofai-sdk` still declares no +dependencies at all, and `import failproofai_sdk` loads nothing outside the +standard library. See `skill/references/frameworks.md` for the per-framework +mapping, and `docs/` for a per-framework integration guide with runnable +examples beside it. + +## Scopes + +The same identity layer, for code the adapters do not cover. `session_id` and +`agent_id` are optional on every event method — omitted, they resolve from the +enclosing scope: + +```python +with failproofai_sdk.session() as sid: + with failproofai_sdk.agent("planner", goal=question): + + with failproofai_sdk.tool_call("search", input={"q": q}) as t: + t.output = search(q) # tool_use / tool_result, timed + + with failproofai_sdk.agent("writer"): # a sub-agent; parent inferred + failproofai_sdk.event.model_request(model="...") +``` + +`agent()` brackets a run with `agent_start`/`agent_end` and records an `error` +before the end event when the block raises — a cancellation closes it as +`cancelled` rather than failed. Every scope works under `async with` too. + +Contextvars do **not** cross into a new thread, so hand work over with +`propagate`: + +```python +pool.submit(failproofai_sdk.propagate(work), item) +``` + +Nothing bound and nothing passed raises `TypeError` naming the fix. It is never +a silent emit: ingest skips an event with no session and answers `200`. + +## Quick start + +```python +import failproofai_sdk + +# Call once at startup. Omit to use defaults ($AGENTEYE_HOME, else +# ~/.failproofai/custom-agents; 500ms flush interval). +failproofai_sdk.configure(base_dir=None, flush_interval=0.5) + +# Emit events via failproofai_sdk.event.<method>(...) +failproofai_sdk.event.agent_start(session_id="run-001", agent_id="planner", goal="answer user query") + +failproofai_sdk.event.tool_use( + session_id="run-001", + agent_id="planner", + tool_name="web_search", + tool_call_id="toolu_01", + input={"query": "latest AI research"}, +) + +failproofai_sdk.event.tool_result( + session_id="run-001", + agent_id="planner", + tool_name="web_search", + tool_call_id="toolu_01", # matches tool_use — SDK auto-computes duration_ms + output={"results": ["..."]}, +) + +failproofai_sdk.event.agent_end(session_id="run-001", agent_id="planner", outcome="success") +``` + +## configure() + +```python +failproofai_sdk.configure( + base_dir=None, # Path | str | None. Default: $AGENTEYE_HOME, else + # ~/.failproofai/custom-agents (honours $FAILPROOFAI_HOME) + flush_interval=0.5, # float, seconds between flush cycles + environment=None, # str | None. Else $AGENTEYE_ENVIRONMENT, else "dev" +) +``` + +Call once before any `event.*` call. Safe to omit — defaults work out of the box. +When `base_dir` is `None`, the SDK reads `$AGENTEYE_HOME` if set, otherwise +spools to `~/.failproofai/custom-agents` (honouring `$FAILPROOFAI_HOME`). + +**The default spool root moved.** It was `~/.agenteye`. The daemon this SDK ships +beside, `failproofaid`, watches **both** roots and always has, so on a host +running it this changes which directory the files land in and nothing else. +Batches already sitting in `~/.agenteye/events` are not orphaned — they stay put +and are still collected; that directory simply stops growing. + +> [!IMPORTANT] +> **If you run the older `agenteye-collector`, set `AGENTEYE_HOME=~/.agenteye`.** +> That collector resolves `$AGENTEYE_HOME` or `~/.agenteye` and nothing else, so +> the new default writes where it does not look — no upload, no error, and an +> unread spool looks exactly like an idle one. `AGENTEYE_HOME` is the documented +> way back precisely because *both* daemons honour it. + +`AGENTEYE_SPOOL_TO_FAILPROOFAI` is **retired**. It selected this root, but also +required the directory to already exist — and nothing ever created it, so the +opt-in never fired. Anyone who set it already wanted this and now gets it. + +## Event reference + +All event methods share two required fields: + +| Field | Type | Description | +|-------|------|-------------| +| `session_id` | `str` | Identifies the top-level agent run | +| `agent_id` | `str` | Identifies which agent within the session emitted the event | + +Every method also accepts arbitrary `**fields` for custom metadata (see [Custom fields](#custom-fields)). + +--- + +### `event.tool_use()` + +Emitted when an agent invokes a tool. Pair with `tool_result` — the SDK auto-computes `duration_ms`. + +```python +failproofai_sdk.event.tool_use( + session_id="run-001", + agent_id="planner", + tool_name="web_search", # str, required + tool_call_id="toolu_01", # str, required — correlation key for the matching tool_result + input={"query": "..."}, # dict | None +) +``` + +--- + +### `event.tool_result()` + +Emitted when a tool returns. Correlates with `tool_use` via `tool_call_id`. + +```python +failproofai_sdk.event.tool_result( + session_id="run-001", + agent_id="planner", + tool_name="web_search", + tool_call_id="toolu_01", # must match the prior tool_use + output={"results": ["..."]}, # Any | None + error=None, # str | None — set if the tool raised + # duration_ms is computed automatically — do not pass it +) +``` + +--- + +### `event.model_request()` + +Emitted just before sending a prompt to an LLM. + +```python +failproofai_sdk.event.model_request( + session_id="run-001", + agent_id="planner", + model="claude-opus-4-6", # str | None + messages=[ # list[dict] | None — conversation turns + {"role": "user", "content": "..."}, + ], + system="You are helpful.", # Any | None — str or list of content blocks + tools=[ # list[dict] | None — tool schemas offered to the model + {"name": "search", "input_schema": {"type": "object"}}, + ], +) +``` + +`messages` entries accept either a plain string `content` or Anthropic-style list-of-blocks `content`. Sampling params (`temperature`, `max_tokens`, etc.) can be passed as extra kwargs. + +--- + +### `event.model_response()` + +Emitted when the LLM returns a response. + +```python +failproofai_sdk.event.model_response( + session_id="run-001", + agent_id="planner", + model="claude-opus-4-6", # str | None + stop_reason="end_turn", # str | None + input_tokens=1024, # int | None + output_tokens=256, # int | None + content=[ # Any | None — str, or list of content blocks + {"type": "text", "text": "..."}, + ], + role="assistant", # str | None +) +``` + +`content` accepts either a plain string (generic providers) or a list of Anthropic-style content blocks. Tool calls live inside `content` as `{"type": "tool_use", ...}` blocks — no separate `tool_calls` field. + +--- + +### `event.agent_start()` + +Emitted when an agent begins work. + +```python +failproofai_sdk.event.agent_start( + session_id="run-001", + agent_id="planner", + goal="answer user query", # str | None + parent_id=None, # str | None — parent agent_id for nested agents +) +``` + +--- + +### `event.agent_end()` + +Emitted when an agent finishes work. + +```python +failproofai_sdk.event.agent_end( + session_id="run-001", + agent_id="planner", + outcome="success", # str | None + summary="Answered query", # str | None +) +``` + +--- + +### `event.agent_pause()` + +Emitted when an agent is suspended (e.g. waiting for human input, user-requested +pause, throttling). Does **not** end the agent — pair it with `agent_resume`, and +the SDK auto-computes the paused `duration_ms`. Emit `agent_resume` instead of a +second `agent_start` when the agent continues. + +```python +failproofai_sdk.event.agent_pause( + session_id="run-001", + agent_id="planner", + pause_id="pause-abc", # str, required — correlation key (reuse it on agent_resume) + reason="waiting_for_user", # str | None + user_id="usr_42", # str | None — who paused, if user-initiated +) +``` + +`pause_id` is emitted on both events, so a pause always pairs to its resume — even +when they happen in different processes (in that case `duration_ms` is omitted and +the interval is derived downstream from the two timestamps). + +--- + +### `event.agent_resume()` + +Emitted when a paused agent continues. Correlates with `agent_pause` via `pause_id`; +the SDK auto-computes `duration_ms` (how long the agent was paused). + +```python +failproofai_sdk.event.agent_resume( + session_id="run-001", + agent_id="planner", + pause_id="pause-abc", # str, required — must match the prior agent_pause + reason="user_resumed", # str | None + user_id="usr_42", # str | None + # duration_ms is computed automatically — do not pass it +) +``` + +--- + +### `event.hook_triggered()` + +Emitted when a hook fires. Pair with `hook_completed` — the SDK auto-computes `duration_ms`. + +```python +failproofai_sdk.event.hook_triggered( + session_id="run-001", + agent_id="planner", + hook_name="pre_tool_use", # str, required + hook_id="hook-abc", # str, required — correlation key + trigger_event="tool_use", # str | None + input={"tool": "search"}, # Any | None +) +``` + +--- + +### `event.hook_completed()` + +Emitted when a hook finishes. Correlates with `hook_triggered` via `hook_id`. + +```python +failproofai_sdk.event.hook_completed( + session_id="run-001", + agent_id="planner", + hook_name="pre_tool_use", + hook_id="hook-abc", # must match the prior hook_triggered + outcome="allow", # str | None + output=None, # Any | None + error=None, # str | None + # duration_ms is computed automatically — do not pass it +) +``` + +--- + +### `event.error()` + +Emitted when an unhandled error occurs. + +```python +failproofai_sdk.event.error( + session_id="run-001", + agent_id="planner", + error_type="TimeoutError", # str, required + message="timed out", # str, required + traceback="Traceback...", # str | None +) +``` + +--- + +## Custom fields + +Any extra keyword arguments are appended to the event after the standard fields: + +```python +failproofai_sdk.event.tool_use( + session_id="run-001", + agent_id="planner", + tool_name="db_query", + tool_call_id="toolu_02", + request_id="req-123", # custom field + tenant_id="acme", # custom field +) +``` + +The field names `timestamp`, `type`, and `environment` are reserved and raise +`ValueError` if passed as custom fields. `session_id` and `agent_id` are required +parameters and cannot be supplied a second time. Set the environment with +`configure(environment=...)` or `AGENTEYE_ENVIRONMENT`. + +Keep payloads as structured JSON when downstream queries need their fields. Values +JSON does not natively support—such as datetimes, UUIDs, decimals, sets, bytes, or +model objects—are converted to strings so the writer can continue flushing the batch. + +## JSONL output + +Events are buffered in-process and flushed to disk every `flush_interval` seconds (default 500ms). +Each flush writes one JSONL file: + +``` +~/.failproofai/custom-agents/events/event-2026-04-01T12-00-00-000Z-48213-7.jsonl +``` + +Each line is one JSON object. Example: + +```json +{"timestamp": "2026-04-01T12:00:00.000000Z", "session_id": "run-001", "agent_id": "planner", "type": "agent_start", "goal": "answer user query"} +{"timestamp": "2026-04-01T12:00:00.123456Z", "session_id": "run-001", "agent_id": "planner", "type": "tool_use", "tool_name": "web_search", "tool_call_id": "toolu_01"} +``` + +The batch is published by writing a `.tmp` file and atomically renaming it to +`.jsonl`, so a daemon polling the directory never reads a half-written file. The +trailing `<pid>-<seq>` keeps two batches written in the same millisecond — by two +threads, or by two agent processes sharing the spool — from overwriting each +other. You do not need to manage these files directly. + +## Development + +```bash +# Install dev dependencies +uv sync --locked --extra dev + +# Run the test suite +uv run pytest tests/ -v + +# Run a single test +uv run pytest tests/test_sdk.py -k duration -v +``` + +| Suite | What it holds | +|---|---| +| `test_sdk.py` | The public API — every event method, unit and on-disk | +| `test_wire_format.py` | Golden bytes for all 15 event types, frozen | +| `test_server_contract.py` | The keys ingest promotes to indexed columns | +| `test_spool_contract.py` | Agreement with the daemons that read the spool | +| `test_durability.py` | Concurrency, crash and retry paths — nothing silently lost | +| `test_zero_dependencies.py` | The stdlib-only guarantee | +| `test_no_customer_identifiers.py` | Nothing private ships in a public wheel | + +Two suites reach for sources outside this package. `test_spool_contract.py` reads +the Rust and TypeScript in this repo and never skips; set +`FAILPROOFAI_SDK_REQUIRE_CONTRACT=1` (CI does) so a moved file fails instead of +skipping. Set `FP_AGENTEYE_ROOT` to an AgentEye checkout to additionally verify +against the older collector and the live ingest handler. diff --git a/sdk/python/docs/README.md b/sdk/python/docs/README.md new file mode 100644 index 000000000..314dcd43e --- /dev/null +++ b/sdk/python/docs/README.md @@ -0,0 +1,249 @@ +# failproofai-sdk — integration guide + +Plug your agent into Failproof AI. One call, no call-site changes, no ids +threaded through your code. + +```bash +pip install 'failproofai-sdk[langgraph]' +``` + +```python +import failproofai_sdk + +failproofai_sdk.instrument() # auto-detects the frameworks you imported + +with failproofai_sdk.session(): + graph.invoke(...) # recorded +``` + +That is the whole integration. + +--- + +## pick your framework + +| framework | guide | runnable code | +|---|---|---| +| LangChain / LangGraph | [langgraph/](langgraph/) | [langgraph/examples/](langgraph/examples/) | +| CrewAI | [crewai/](crewai/) | [crewai/examples/](crewai/examples/) | +| LlamaIndex | [llama_index/](llama_index/) | [llama_index/examples/](llama_index/examples/) | +| Pydantic AI | [pydantic_ai/](pydantic_ai/) | [pydantic_ai/examples/](pydantic_ai/examples/) | +| **no framework** (your own agent) | [manual/](manual/) | [manual/examples/](manual/examples/) | + +Using something else — AutoGen, Haystack, Semantic Kernel, your own loop? Read +[manual/](manual/). It is the same fidelity, it just costs you the call sites, +and it explains why AutoGen has no adapter. + +--- + +## what you get + +Here is a real trace, printed by `langgraph/examples/quickstart.py`. This is +captured output, not an illustration: + +``` +━━ failproof_ai · langgraph quickstart + session faf3a02d64464e34809eae95fa244230 + events 14 · agents 1 · types 8 + + № offset event detail + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 1 +0.000s agent_start LangGraph + 2 +0.001s hook_triggered agent + 3 +0.002s model_request gpt-5.6-terra + 4 +3.023s model_response gpt-5.6-terra · 21 out-tok + 5 +3.024s hook_completed agent + 6 +3.024s hook_triggered tools + 7 +3.025s tool_use word_count + 8 +3.025s tool_result word_count · ok + 9 +3.025s hook_completed tools + 10 +3.026s hook_triggered agent + 11 +3.027s model_request gpt-5.6-terra + 12 +5.717s model_response gpt-5.6-terra · 5 out-tok + 13 +5.720s hook_completed agent + 14 +5.721s agent_end LangGraph · success +``` + +One `graph.invoke()` from the outside. Fourteen events with timings, token +counts and the tool call, from the inside. + +--- + +## the three calls + +### 1. `configure()` — optional + +```python +failproofai_sdk.configure( + environment="production", # label on every event; default "dev" + flush_interval=0.5, # seconds between disk flushes + base_dir=None, # spool root; default ~/.failproofai/custom-agents +) +``` + +Defaults are usually right. Set `environment` so you can tell prod from staging +on the dashboard. + +### 2. `instrument()` — the one that does the work + +```python +failproofai_sdk.instrument() # every framework already imported +failproofai_sdk.instrument("crewai") # exactly one +failproofai_sdk.uninstrument() # put everything back +``` + +Auto-detection reads `sys.modules` — a framework you have installed but are not +using is never imported on your behalf. + +### 3. `session()` — group one run + +```python +with failproofai_sdk.session(): + ... +``` + +A session is one run. Without it, each top-level call becomes its own session, +which is almost never what you want. + +--- + +## the fifteen event types + +| group | events | what they mean | +|---|---|---| +| agents | `agent_start` `agent_end` | a unit of work begins / ends | +| | `agent_pause` `agent_resume` | it is blocked / unblocked (feeds paused time) | +| models | `model_request` `model_response` | one LLM round trip, with tokens | +| tools | `tool_use` `tool_result` | one tool call, with args and output | +| hooks | `hook_triggered` `hook_completed` | a node / step / task boundary | +| humans | `human_wait` `human_input` | you asked a person / they answered | +| | `human_pause` `human_interrupt` | a person paused / stopped the agent | +| failures | `error` | something went wrong | + +**Events come in pairs.** The closing event carries a `duration_ms` measured +from the matching opening one. An opening event with no close renders as a span +that never finishes. + +--- + +## what each framework emits + +Measured from the real runs in this directory, not declared. + +| event | langgraph | crewai | llama_index | pydantic ai | your own | +|---|:--:|:--:|:--:|:--:|:--:| +| `agent_start` / `agent_end` | yes | yes | yes | yes | you | +| `model_request` / `model_response` | yes | yes | yes | yes | you | +| `tool_use` / `tool_result` | yes | yes | yes | yes | you | +| `hook_triggered` / `hook_completed` | node | task | step | — | you | +| `error` | yes | yes | yes | yes | automatic | +| `human_wait` / `human_input` | yes | yes | yes | — | you | +| `agent_pause` / `agent_resume` | yes | yes | yes | — | you | +| `human_pause` / `human_interrupt` | — | — | — | — | you | + +A dash means the framework has no such concept, not that it is missing. +Pydantic AI has no node boundary and no built-in human pause, so there is +nothing to map. `human_pause` and `human_interrupt` describe a *person* acting +on the agent — a stop button — which no framework signals; emit those yourself. + +--- + +## running the examples + +Every example in this tree was executed against a live model before it shipped. + +```bash +pip install 'failproofai-sdk[langgraph]' +export OPENAI_API_KEY=sk-... +export FPAI_MODEL=gpt-4o-mini # optional, this is the default + +python docs/langgraph/examples/quickstart.py +``` + +Each one prints the event stream it produced when it finishes. That printer +lives in [`_shared/`](_shared/) and is **cosmetics only** — the two bootstrap +lines at the top of each example exist just to import it. Delete them and the +example still instruments correctly. + +--- + +## instrumentation cannot break your agent + +Every callback goes through a wrapper whose only job is to re-raise. Your call +sits in exactly one `try`; everything the SDK does happens outside it. + +- a hook that raises is logged once, with its traceback, and your call is + unaffected; +- a hook that raises three times at the same site is disabled for the rest of + the process, with one error line saying so. + +That firewall is the right default in production and the wrong one when you are +debugging. To make failures loud instead: + +```bash +export FAILPROOFAI_SDK_STRICT=1 +``` + +Without it you can only ever prove "it did not crash", never "it swallowed the +right thing". + +--- + +## zero dependencies + +`import failproofai_sdk` pulls in nothing outside the standard library. That is +enforced, not asserted: one test installs the built wheel with `--no-deps`, +another launches a fresh interpreter and proves no framework reaches +`sys.modules`. + +The extras install the **framework**, not the adapter — the adapter code always +ships in the base wheel and is imported only when `instrument()` asks for it. +Most people already have the framework and never need an extra. + +| extra | installs | +|---|---| +| `failproofai-sdk[langchain]` | `langchain-core>=1.4.7,<2` | +| `failproofai-sdk[langgraph]` | `langgraph>=1.2,<2` | +| `failproofai-sdk[crewai]` | `crewai>=1.13,<2` | +| `failproofai-sdk[llamaindex]` | `llama-index-core>=0.14.23,<0.15` | +| `failproofai-sdk[pydantic-ai]` | `pydantic-ai-slim>=2.0,<3` | + +There is deliberately no `[all]` — an extra that installs four agent frameworks +at once is a resolver problem handed to somebody who wanted a telemetry library. + +--- + +## verifying it works + +Look at the dashboard, or query the store. + +**Do not read the spool directory to check.** When `failproofaid` is running it +collects and deletes each batch file within milliseconds of it appearing, so a +read races the collector and returns only what has not shipped yet — which looks +exactly like an adapter that emitted nothing but its closing events. This is a +real thing that happened while writing these docs. + +To inspect events in-process instead, tap the writer: + +```python +captured = [] +_original = failproofai_sdk._writer.submit +failproofai_sdk._writer.submit = lambda e: (captured.append(e), _original(e))[1] +``` + +Every example here does exactly that. + +--- + +## troubleshooting + +| symptom | most likely cause | +|---|---| +| no events at all | `instrument()` never ran, or ran after you built the agent (Pydantic AI) | +| events, but no session | no `with failproofai_sdk.session():` — each call became its own session | +| `TypeError: ... missing session_id` | you emitted from a new thread; wrap it in `failproofai_sdk.propagate()` | +| spans that never finish | an opening event with no closing one — emit the pairs | +| null token counts on LlamaIndex | streaming has no usage; see [llama_index/](llama_index/) | +| `agent_id` full of uuids | keep it low-cardinality — a role or node name, never a run id | +| duplicated events on LangChain | you registered a second callback handler as well as `instrument()` | +| adapter seems to do nothing | set `FAILPROOFAI_SDK_STRICT=1` and run again | diff --git a/sdk/python/docs/_shared/README.md b/sdk/python/docs/_shared/README.md new file mode 100644 index 000000000..afb42e387 --- /dev/null +++ b/sdk/python/docs/_shared/README.md @@ -0,0 +1,43 @@ +# _shared + +Cosmetics for the runnable examples. **Nothing here is part of the SDK**, and no +example needs it to instrument correctly. + +Two helpers: + +- `model()` — reads `FPAI_MODEL` / `MODEL`, defaults to `gpt-4o-mini`, so one + export drives every example. +- `trace(session_id)` — prints the event stream the run produced. + +`trace()` is why the examples are worth running rather than reading. An adapter +that "works" is one whose event stream you can see, so every example ends by +printing its own instead of asserting in a comment that events happened. + +## why it taps the writer instead of reading the spool + +When `failproofaid` is running it collects and **deletes** each batch file +within milliseconds of it appearing. A spool read therefore races the collector +and returns only whatever has not shipped yet — which looks exactly like an +adapter that emitted nothing but its closing events. + +That is not hypothetical: it is what happened the first time these examples were +run, and it read as three adapter bugs that did not exist. + +So `capture()` wraps `failproofai_sdk._writer.submit` and appends every entry to +a list first. Same dict that goes to disk, race-free, and it works whether or not +a daemon is running. `banner()` calls it, so every example is tapped from its +first line. + +## removing it + +Each example starts with two bootstrap lines that exist only to import this +package: + +```python +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +... +from _shared import banner, model, trace +``` + +Delete those and the `banner()` / `trace()` calls, and the example still +instruments correctly. It just stops printing. diff --git a/sdk/python/docs/_shared/__init__.py b/sdk/python/docs/_shared/__init__.py new file mode 100644 index 000000000..971e4a169 --- /dev/null +++ b/sdk/python/docs/_shared/__init__.py @@ -0,0 +1,195 @@ +"""Cosmetics shared by the examples. No SDK behaviour lives here. + +Two helpers, both deliberately boring: + +* `model()` — the model id, from `FPAI_MODEL` / `MODEL`, so one export drives + every example instead of a dozen edits. +* `trace()` — reads the spool back and prints what the run actually recorded. + +`trace()` is the point of these examples. An adapter that "works" is one whose +event stream you can read, so every example ends by printing its own trace +rather than asserting in a comment that events were produced. It reads the same +JSONL the daemon collects, filtered to the session just run. + +Nothing here is required to use the SDK. Delete this folder and the examples +still instrument correctly; they just stop printing. +""" +from __future__ import annotations + +import json +import os +import pathlib +import sys + +import failproofai_sdk + +# ── brand ──────────────────────────────────────────────────────────────────── +# Exact hex from the design system: pink #e4587d, mint #66d1b5, ink #d8d6d2. +# Truecolor, and only when stdout is a terminal — a piped log gets clean text. +_TTY = sys.stdout.isatty() + + +def _c(code: str, text: str) -> str: + return f"\033[{code}m{text}\033[0m" if _TTY else text + + +def pink(t: str) -> str: + return _c("38;2;228;88;125", t) + + +def mint(t: str) -> str: + return _c("38;2;102;209;181", t) + + +def dim(t: str) -> str: + return _c("38;2;120;120;130", t) + + +def bold(t: str) -> str: + return _c("1", t) + + +RULE = "━" * 68 + + +def model() -> str: + """The model id every example runs against.""" + return os.environ.get("FPAI_MODEL") or os.environ.get("MODEL") or "gpt-4o-mini" + + +def banner(title: str, subtitle: str = "") -> None: + """Print the example's header and start capturing events.""" + capture() + print() + print(pink("━━ ") + bold(title.lower())) + if subtitle: + print(dim(" " + subtitle)) + print() + + +# ── capturing what the run emitted ─────────────────────────────────────────── +# NOT by reading the spool. When `failproofaid` is running it collects and +# DELETES each batch file within milliseconds of it appearing, so a spool read +# races the daemon and returns whatever happens to be left — which looks exactly +# like an adapter that only emitted its closing events. (That is a real thing +# that happened while writing these examples.) +# +# So the tap sits on the writer instead: the same dict that goes to disk is +# appended to a list first. Race-free, and it works whether or not a daemon is +# running. +_CAPTURED: list[dict] = [] +_TAPPED = False + + +def capture() -> None: + """Start recording every event this process emits. Idempotent.""" + global _TAPPED + if _TAPPED: + return + writer = failproofai_sdk._writer + original = writer.submit + + def _tee(entry: dict) -> None: + _CAPTURED.append(entry) + return original(entry) + + writer.submit = _tee # instance attribute; the real method is untouched + _TAPPED = True + + +def events(session_id: str | None = None) -> list[dict]: + """Everything captured, optionally narrowed to one session.""" + rows = [r for r in _CAPTURED if session_id is None or r.get("session_id") == session_id] + return sorted(rows, key=lambda r: r.get("timestamp", "")) + + +_LEAF = { + "tool_use": "tool_result", + "model_request": "model_response", + "hook_triggered": "hook_completed", + "agent_start": "agent_end", + "human_wait": "human_input", + "agent_pause": "agent_resume", +} +_CLOSERS = set(_LEAF.values()) + + +def _detail(row: dict) -> str: + t = row["type"] + if t in ("tool_use", "tool_result"): + bits = [row.get("tool_name") or "?"] + if row.get("error"): + bits.append(pink("error")) + elif t == "tool_result": + bits.append(mint("ok")) + return " · ".join(bits) + if t in ("model_request", "model_response"): + bits = [row.get("model") or "?"] + tok = row.get("output_tokens") + if tok: + bits.append(f"{tok} out-tok") + if row.get("error"): + bits.append(pink("error")) + return " · ".join(bits) + if t in ("agent_start", "agent_end"): + bits = [row.get("agent_id") or "?"] + if row.get("parent_id"): + bits.append(dim("under " + row["parent_id"])) + if row.get("outcome"): + good = row["outcome"] == "success" + bits.append((mint if good else pink)(row["outcome"])) + return " · ".join(bits) + if t in ("hook_triggered", "hook_completed"): + return " · ".join(x for x in (row.get("hook_name"), row.get("status")) if x) + if t == "error": + return pink(str(row.get("message", ""))[:48]) + for key in ("prompt", "reason", "response", "summary", "goal"): + if row.get(key): + return dim(str(row[key])[:48]) + return "" + + +def trace(session_id: str, *, title: str = "trace") -> list[dict]: + """Print the event stream this run produced. Returns the rows.""" + rows = events(session_id) + if not rows: + print(pink(" no events — is the spool configured?")) + return rows + + kinds = sorted({r["type"] for r in rows}) + agents = sorted({r.get("agent_id") for r in rows if r.get("agent_id")}) + t0 = rows[0]["timestamp"] + + print(pink("━━ ") + bold(f"failproof_ai · {title}")) + print(dim(f" session {session_id}")) + print(dim(f" events {len(rows)} · agents {len(agents)} · types {len(kinds)}")) + print() + print(dim(f" {'№':>3} {'offset':>8} {'event':<16} detail")) + print(dim(" " + RULE)) + + depth = 0 + for i, row in enumerate(rows, 1): + t = row["type"] + if t in _CLOSERS: + depth = max(depth - 1, 0) + indent = " " * depth + offset = _offset(t0, row["timestamp"]) + name = f"{indent}{t}" + print(f" {i:>3} {dim(offset):>8} {name:<16} {_detail(row)}") + if t in _LEAF: + depth += 1 + print(dim(" " + RULE)) + print(dim(" " + " ".join(kinds))) + print() + return rows + + +def _offset(first: str, ts: str) -> str: + from datetime import datetime + + try: + a = datetime.fromisoformat(first.replace("Z", "+00:00")) + b = datetime.fromisoformat(ts.replace("Z", "+00:00")) + return f"+{(b - a).total_seconds():.3f}s" + except Exception: + return "" diff --git a/sdk/python/docs/crewai/README.md b/sdk/python/docs/crewai/README.md new file mode 100644 index 000000000..6d0ad958f --- /dev/null +++ b/sdk/python/docs/crewai/README.md @@ -0,0 +1,328 @@ +# CrewAI + +- [Install](#install) +- [The integration](#the-integration) +- [How it attaches](#how-it-attaches) +- [What gets recorded](#what-gets-recorded) +- [A complete example](#a-complete-example) +- [Agent names come from roles](#agent-names-come-from-roles) +- [Options](#options) +- [Human in the loop](#human-in-the-loop) +- [Pitfalls](#pitfalls) +- [Runnable examples](#runnable-examples) + +--- + +## Install + +```bash +pip install 'failproofai-sdk[crewai]' # pins crewai >=1.13,<2 +``` + +**Supported:** `crewai` 1.13 → 2.0. 1.13 is a capability floor, not a guess — it +is the release that added `started_event_id` and normalised token usage, both of +which the adapter relies on to pair events and report tokens. + +--- + +## The integration + +```python +import failproofai_sdk + +failproofai_sdk.configure(environment="production") +failproofai_sdk.instrument() + +with failproofai_sdk.session(): + Crew(agents=[analyst, writer], tasks=[gather, summarise]).kickoff() +``` + +Nothing about your crew, agents, tasks or tools changes. + +--- + +## How it attaches + +`instrument()` constructs a listener on CrewAI's module-level +`crewai_event_bus` and subscribes one handler per event class. + +Two details that matter if you are debugging: + +**The bus dispatches by exact type, with no MRO walk.** There is no `BaseEvent` +catch-all to subscribe to, so the adapter carries an explicit table of every +class it maps. A CrewAI release that renames an event class disables exactly +that one hook (with a warning) rather than breaking the adapter. + +**Every handler is `async def`, deliberately.** The bus dispatches *sync* +handlers onto a ten-worker pool where submission order is not execution order. +Measured on crewai 1.15.8, sync handlers produced a demonstrably wrong event +stream — pairs closing before they opened. Async handlers run in order. + +--- + +## What gets recorded + +| CrewAI | Failproof event | When | +|---|---|---| +| crew kickoff | `agent_start` / `agent_end` | the crew starts and finishes | +| flow start / finish | `agent_start` / `agent_end` | a flow you wrote | +| agent execution | nested `agent_start` / `agent_end` | `agent_id` = the **role** | +| task | *nothing* — recorded as a link | see below | +| flow method | `hook_triggered` / `hook_completed` | each `@start` / `@listen` method | +| guardrail | `hook_triggered` / `hook_completed` | each LLM guardrail | +| tool usage | `tool_use` / `tool_result` | each tool call | +| memory query / save / retrieve | `tool_use` / `tool_result` | named `memory.query`, `memory.save`, … | +| knowledge query / retrieve | `tool_use` / `tool_result` | named `knowledge.query`, … | +| LLM call | `model_request` / `model_response` | each model call, with usage | +| LLM stream chunk | *nothing* | folded into `fw_chunks`, `fw_ttft_ms` | +| human feedback requested | `human_wait` + `agent_pause` | the flow blocks on a person | +| human feedback received | `agent_resume` + `human_input` | the person answers | +| agent execution error | `error` + `agent_end(outcome="failed")` | the agent raises | + +### Why a task emits nothing + +A CrewAI task is a subset of the agent execution that runs it. Emitting both +would double every row in the timeline and render them as siblings, which is +simply wrong — the task *contains* the agent work, it does not run beside it. + +The task is still recorded: it is registered as a link so its children resolve +to the crew above them, and its id and name ride along on the agent's own events +as `fw_task_id` and `fw_task_name`. You can filter by task; you just do not get +a duplicate span. + +### Why memory and knowledge ops are tools + +They are retrieval calls the agent makes. Naming them for the surface they hit +(`memory.query`, `knowledge.retrieve`) rather than the class that fired means +they show up on the tools page next to your real tools, where you actually want +to compare their latency. + +--- + +## A complete example + +```python +import failproofai_sdk +from crewai import Agent, Crew, Process, Task +from crewai.tools import tool + +failproofai_sdk.configure(environment="production") +failproofai_sdk.instrument() + +MODEL = "openai/gpt-4o-mini" + +METRICS = { + "revenue": "$4.2M ARR, up 12% QoQ", + "churn": "3.1% monthly logo churn, up from 2.4%", + "nps": "41, flat", +} + + +@tool("lookup_metric") +def lookup_metric(name: str) -> str: + """Look up a business metric by name. Valid: revenue, churn, nps.""" + return METRICS.get(name.lower().strip(), "unknown metric") + + +analyst = Agent( + role="analyst", # <- this becomes agent_id + goal="pull the numbers that matter and state them plainly", + backstory="You read dashboards for a living and distrust round numbers.", + tools=[lookup_metric], + llm=MODEL, +) +writer = Agent( + role="writer", + goal="turn numbers into three lines an exec will actually read", + backstory="You write board updates. You never pad.", + llm=MODEL, +) + +gather = Task( + description="Look up 'revenue', 'churn' and 'nps' with the tool.", + expected_output="Three lines, one metric each.", + agent=analyst, +) +summarise = Task( + description="Using the metrics above, write a three-line exec summary.", + expected_output="Exactly three lines.", + agent=writer, + context=[gather], +) + +with failproofai_sdk.session(): + result = Crew( + agents=[analyst, writer], + tasks=[gather, summarise], + process=Process.sequential, + ).kickoff() + +print(result) +``` + +This produces **17–18 events across 6 types**, with the handoff visible: + +``` + 1 +0.000s agent_start crew + 2 +0.049s agent_start analyst · under crew + 3 +0.055s model_request gpt-5.6-terra + 4 +3.112s model_response gpt-5.6-terra · 65 out-tok + 5 +3.114s tool_use lookup_metric + ... + 13 +5.129s agent_end analyst · success + 14 +5.149s agent_start writer · under crew + 15 +5.156s model_request gpt-5.6-terra + 16 +8.177s model_response gpt-5.6-terra · 48 out-tok + 17 +8.205s agent_end writer · success + 18 +8.224s agent_end crew · success +``` + +Two roles, two spans, one session — you can now break latency and token spend +down per role. + +--- + +## Agent names come from roles + +`agent_id` is taken from `Agent(role=...)`. That is what makes it a useful +dashboard facet instead of a UUID nobody can read. + +```python +Agent(role="analyst", ...) # agent_id = "analyst" good +Agent(role="analyst-7f3a2b", ...) # one facet entry per run bad +``` + +`agent_id` is a `LowCardinality(String)` column. A role containing a run id or a +timestamp degrades that column for every query anyone runs. Keep roles short, +human, and stable across runs. + +If a role looks like an id (a UUID, a long hex string), the adapter refuses it +and falls back to a safe default, putting the real value in `fw_agent_id`. + +--- + +## Options + +```python +failproofai_sdk.instrument( + "crewai", + session_id=None, # pin every run to one session id +) +``` + +`session_id` is the only option this adapter reads — there is no +`capture_content` here. Prompts and completions are always recorded, truncated +to the payload budget. + +Session identity resolves as: the `session_id` option, then the enclosing +`failproofai_sdk.session()` scope, then a generated `uuid4().hex` per crew or +flow. + +--- + +## Human in the loop + +CrewAI's flow runtime emits `HumanFeedbackRequestedEvent` before it blocks on a +person and `HumanFeedbackReceivedEvent` after the answer. Both are recorded, as +four events: + +``` +human_wait prompt, options +agent_pause starts the paused-time clock + ... a person is reading ... +agent_resume stops it — this is what feeds paused time +human_input the answer, with the wait measured +``` + +Neither pair is redundant. `human_wait` → `human_input` carries the prompt and +the answer; `agent_pause` → `agent_resume` is the only thing that feeds paused +time. Without the second pair, a ten-minute human wait is billed as ten minutes +of active agent time. + +> **CrewAI sets no correlation id on either event.** `request_id` is `None` on +> both and `started_event_id` is `None` on the received one, so there is nothing +> to join on directly. The adapter pairs on `request_id` when present (the +> enterprise async provider does set it), then on `(flow_name, method_name)`, +> then on the most recently opened pause. The last fallback is sound because a +> console prompt blocks — two cannot interleave. **If you build your own +> concurrent feedback provider, set `request_id` on both events.** + +Feedback arriving for a request that was never seen — a flow resumed in another +process, say — records the answer but deliberately withholds `agent_resume`, +because closing a pause that never opened would subtract an interval that was +never added. + +--- + +## Pitfalls + +### Roles with ids in them wreck the facet + +**Symptom:** the agent filter on the dashboard has thousands of entries. + +**Cause:** `role` contains a UUID, a timestamp or a per-run suffix. + +**Fix:** use a stable human role. Put the run-specific id in the task +description or a payload field. + +--- + +### Asserting on events straight after `kickoff()` finds nothing + +**Symptom:** your test reads zero events, but the dashboard shows them. + +**Cause:** the bus is asynchronous. `kickoff()` returns before the last handlers +have run. + +**Fix:** + +```python +from crewai.events.event_bus import crewai_event_bus + +crew.kickoff() +crewai_event_bus.flush(timeout=30) # now read +``` + +This is a property of CrewAI, not of the SDK. + +--- + +### A crew that dies mid-tool leaves an open span + +**Symptom:** a session shows as `ongoing` forever. + +**Cause:** `agent_end` force-closes open *pauses* but not tools or models, so a +run that dies inside a tool call leaves that `tool_use` unclosed. + +**Fix:** none needed in normal operation — `uninstrument()` and process teardown +close whatever is still open, marking it `fw_incomplete` with +`outcome="cancelled"`. If you are killing processes with `SIGKILL`, nothing can +run, and the span stays open. + +--- + +### Nothing is recorded at all + +1. Did `instrument()` run before `kickoff()`? +2. Is there a `with failproofai_sdk.session():` around it? +3. Set `FAILPROOFAI_SDK_STRICT=1` and run again — a degraded hook will raise + instead of being swallowed. +4. Check your `crewai` version is ≥ 1.13. Below that, `started_event_id` does + not exist and pairing falls back to a heuristic. + +--- + +## Runnable examples + +| file | what it shows | events | +|---|---|---| +| [`examples/quickstart.py`](examples/quickstart.py) | one agent, one task, one tool | 10 | +| [`examples/research_crew.py`](examples/research_crew.py) | 2 agents, 2 tasks, sequential handoff | 17 | + +```bash +export OPENAI_API_KEY=sk-... +export FPAI_MODEL=gpt-4o-mini +python docs/crewai/examples/quickstart.py +``` + +Both were run against a live model before shipping. diff --git a/sdk/python/docs/crewai/examples/quickstart.py b/sdk/python/docs/crewai/examples/quickstart.py new file mode 100644 index 000000000..f0b69e288 --- /dev/null +++ b/sdk/python/docs/crewai/examples/quickstart.py @@ -0,0 +1,61 @@ +"""crewai — the smallest thing that produces a trace. + + pip install 'failproofai-sdk[crewai]' + python docs/crewai/examples/quickstart.py + +`instrument()` registers a listener on crewai's event bus, so the crew, each +agent and each tool call are recorded. The task gets no span of its own — it is +the agent execution that runs it, and rides along on that agent's events as +`fw_task_id`/`fw_task_name`. `agent_id` comes from the agent's `role`, which is +what makes it a useful dashboard facet rather than a uuid nobody can read. +""" +import sys +from pathlib import Path + +# Only so this file can import the shared trace printer from docs/_shared/. +# Delete these two lines and the `_shared` import below and the example still +# instruments correctly — it just stops printing its own trace at the end. +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +import failproofai_sdk +from _shared import banner, model, trace +from crewai import Agent, Crew, Task +from crewai.tools import tool + +failproofai_sdk.configure(environment="examples") +failproofai_sdk.instrument() + +MODEL = f"openai/{model()}" + + +@tool("lookup_metric") +def lookup_metric(name: str) -> str: + """Look up a business metric by name.""" + return {"revenue": "$4.2M", "churn": "3.1%"}.get(name, "unknown") + + +def main() -> None: + banner("crewai quickstart", "one agent, one task, one tool") + + analyst = Agent( + role="analyst", + goal="find the revenue number", + backstory="You read metrics.", + tools=[lookup_metric], + llm=MODEL, + ) + task = Task( + description="Look up 'revenue' with the tool.", + expected_output="The revenue figure.", + agent=analyst, + ) + + with failproofai_sdk.session() as sid: + result = Crew(agents=[analyst], tasks=[task]).kickoff() + + print(" answer:", str(result).strip()[:120]) + trace(sid, title="crewai quickstart") + + +if __name__ == "__main__": + main() diff --git a/sdk/python/docs/crewai/examples/research_crew.py b/sdk/python/docs/crewai/examples/research_crew.py new file mode 100644 index 000000000..40dcb022c --- /dev/null +++ b/sdk/python/docs/crewai/examples/research_crew.py @@ -0,0 +1,92 @@ +"""crewai — two agents, two tasks, a shared tool, sequential handoff. + + pip install 'failproofai-sdk[crewai]' + python docs/crewai/examples/research_crew.py + +What this demonstrates that the quickstart does not: + +* two `agent_id` values in one session, taken from each agent's `role`, so the + dashboard can break latency and token spend down per role; +* two tasks that add no spans of their own — a crewai task *is* the agent + execution that runs it, so recording both would double every row and render + them as siblings. Each task rides along on its agent's events instead, as + `fw_task_id`/`fw_task_name`, which you can still filter by; +* a second task consuming the first one's output, so the handoff is visible in + the trace rather than implied. +""" +import sys +from pathlib import Path + +# Only so this file can import the shared trace printer from docs/_shared/. +# Delete these two lines and the `_shared` import below and the example still +# instruments correctly — it just stops printing its own trace at the end. +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +import failproofai_sdk +from _shared import banner, model, trace +from crewai import Agent, Crew, Process, Task +from crewai.tools import tool + +failproofai_sdk.configure(environment="examples") +failproofai_sdk.instrument() + +MODEL = f"openai/{model()}" + +_METRICS = { + "revenue": "$4.2M ARR, up 12% QoQ", + "churn": "3.1% monthly logo churn, up from 2.4%", + "nps": "41, flat", +} + + +@tool("lookup_metric") +def lookup_metric(name: str) -> str: + """Look up a business metric by name. Valid names: revenue, churn, nps.""" + return _METRICS.get(name.lower().strip(), "unknown metric") + + +def main() -> None: + banner("crewai research crew", "2 agents · 2 tasks · sequential handoff") + + analyst = Agent( + role="analyst", + goal="pull the numbers that matter and state them plainly", + backstory="You read dashboards for a living and distrust round numbers.", + tools=[lookup_metric], + llm=MODEL, + ) + writer = Agent( + role="writer", + goal="turn numbers into three lines an exec will actually read", + backstory="You write board updates. You never pad.", + llm=MODEL, + ) + + gather = Task( + description=( + "Look up 'revenue', 'churn' and 'nps' with the tool. " + "Report each verbatim, one per line." + ), + expected_output="Three lines, one metric each.", + agent=analyst, + ) + summarize = Task( + description="Using the metrics above, write a three-line exec summary.", + expected_output="Exactly three lines.", + agent=writer, + context=[gather], + ) + + with failproofai_sdk.session() as sid: + result = Crew( + agents=[analyst, writer], + tasks=[gather, summarize], + process=Process.sequential, + ).kickoff() + + print(" summary:", str(result).strip()[:220], "\n") + trace(sid, title="crewai research crew") + + +if __name__ == "__main__": + main() diff --git a/sdk/python/docs/langgraph/README.md b/sdk/python/docs/langgraph/README.md new file mode 100644 index 000000000..e6e17d00b --- /dev/null +++ b/sdk/python/docs/langgraph/README.md @@ -0,0 +1,481 @@ +# LangChain / LangGraph + +One adapter serves both — LangGraph runs on `langchain-core`'s callback manager, +so instrumenting one instruments the other. + +- [Install](#install) +- [The integration](#the-integration) +- [How it attaches](#how-it-attaches) +- [What gets recorded](#what-gets-recorded) +- [A complete example](#a-complete-example) +- [Naming your agents](#naming-your-agents) +- [Controlling the session](#controlling-the-session) +- [Options](#options) +- [What an event actually looks like](#what-an-event-actually-looks-like) +- [Human in the loop](#human-in-the-loop) +- [Pitfalls](#pitfalls) +- [Runnable examples](#runnable-examples) + +--- + +## Install + +```bash +pip install 'failproofai-sdk[langgraph]' # pins langgraph >=1.2,<2 +``` + +Using plain LangChain without LangGraph: + +```bash +pip install 'failproofai-sdk[langchain]' # pins langchain-core >=1.4.7,<2 +``` + +You almost certainly have the framework already. The extras exist to state the +supported range; the adapter itself ships in the base wheel. + +**Supported:** `langchain-core` 1.4.7 → 2.0, `langgraph` 1.2 → 2.0. Outside +that range the adapter still installs and warns once, because a version we have +not tested is a better bet than no telemetry. + +--- + +## The integration + +```python +import failproofai_sdk + +failproofai_sdk.configure(environment="production") +failproofai_sdk.instrument() + +with failproofai_sdk.session(): + graph.invoke({"messages": [HumanMessage("...")]}) +``` + +Three lines, and nothing else in your codebase changes. No decorators on your +nodes, no callback passed to `.invoke()`, no ids threaded through your +functions. + +`instrument()` with no argument auto-detects every supported framework already +imported. To be explicit: + +```python +failproofai_sdk.instrument("langchain") # "langgraph" is an alias +``` + +--- + +## How it attaches + +`instrument()` registers a tracer through +`langchain_core.tracers.context.register_configure_hook`. LangChain injects it +into **every callback manager it builds**, which means: + +- every graph, node, tool, retriever and model call is captured; +- so is anything inside a library you did not write, as long as it goes through + LangChain; +- there is nothing to pass to `.invoke()` and no call site to change. + +The tracer subclasses `BaseTracer`, so LangChain assembles the run tree and +hands over `Run` objects with inputs, outputs, metadata and timings already +collected — two override points instead of twenty hand-correlated callbacks. + +<details> +<summary><b>Why we do not patch <code>BaseCallbackManager.__init__</code></b></summary> + +Some other integrations do, and it double-records. +`BaseCallbackManager.merge()` builds a new manager with handlers already passed +in, so their `isinstance` dedup misses and the handler is added twice. MLflow +patches `merge` as well to work around it. The configure hook has no such hole. + +This is also why you must **not** pass a Failproof handler in +`config={"callbacks": [...]}` yourself — see [Pitfalls](#pitfalls). +</details> + +<details> +<summary><b>Why the handler runs inline</b></summary> + +`run_inline = True` is not optional. `AsyncCallbackManager` dispatches sync +handlers through `run_in_executor` unless a handler opts out, and that hop can +**reorder callbacks** — which scrambles timestamp order and breaks every pairing. +Writing an event is a `deque.append`, so running inline on the event loop is +safe. +</details> + +--- + +## What gets recorded + +| LangChain / LangGraph | Failproof event | When | +|---|---|---| +| root run | `agent_start` / `agent_end` | the outermost `.invoke()` / `.stream()` | +| LangGraph node | `hook_triggered` / `hook_completed` | each node entry and exit | +| compiled subgraph | nested `agent_start` / `agent_end` | a subgraph run, named `root/node` | +| tool run | `tool_use` / `tool_result` | each tool call, with args and output | +| retriever run | `tool_use` / `tool_result` | output summarised, not dumped | +| chat model / LLM run | `model_request` / `model_response` | each model call, with token usage | +| streamed tokens | *nothing* | folded into `fw_chunks`, `fw_ttft_ms` on the response | +| `interrupt()` | `human_wait` + `agent_pause` | the graph suspends for a person | +| `Command(resume=...)` | `agent_resume` + `human_input` | the graph continues | +| unhandled exception | `error` + `agent_end(outcome="failed")` | the run raises | +| intermediate chains | *nothing* | unless named in `include_chains` | + +### Why a node is a hook and not a nested agent + +`agent_id` is a `LowCardinality(String)` column and the primary facet on every +dashboard surface. Promoting `retrieve`, `grade_documents` and `should_continue` +to agents would: + +- drown that facet with one entry per node, and +- label the whole session after whichever node happened to run first. + +Hook spans render structurally identically in the timeline, and you get a +per-node latency page for free. Subgraphs *do* become nested agents, because a +compiled subgraph is a genuine unit of work. + +### Why streamed tokens emit nothing + +A 500-token response would otherwise be 500 stored rows against a five-lane +rail. The chunk count, time-to-first-token and a `fw_streamed` flag land on the +single `model_response` instead. + +--- + +## A complete example + +Copy-pasteable, no helpers: + +```python +import failproofai_sdk +from langchain_core.messages import HumanMessage +from langchain_core.tools import tool +from langchain_openai import ChatOpenAI +from langgraph.prebuilt import ToolNode, create_react_agent + +failproofai_sdk.configure(environment="production") +failproofai_sdk.instrument() + + +@tool +def price_of(item: str) -> float: + """Return the unit price of an item in USD.""" + return {"widget": 42.0, "gadget": 17.5}[item.lower().strip()] + + +@tool +def stock_of(item: str) -> int: + """Return the units of an item currently in stock.""" + return {"widget": 120, "gadget": 0}[item.lower().strip()] + + +# handle_tool_errors=True feeds a raised exception back to the model as a tool +# message instead of aborting the graph. The failure is recorded either way. +tools = ToolNode([price_of, stock_of], handle_tool_errors=True) +graph = create_react_agent(ChatOpenAI(model="gpt-4o-mini"), tools) + +with failproofai_sdk.session(): + with failproofai_sdk.agent("analyst", goal="price and stock report"): + result = graph.invoke({ + "messages": [HumanMessage("Price and stock for widget and gadget?")] + }) + +print(result["messages"][-1].content) +``` + +Running the fuller version of this +([`examples/research_agent.py`](examples/research_agent.py)) produces **36 +events across 8 types**: the `analyst` span, the graph nested inside it, a hook +pair per node, a model pair per turn with token counts, and a tool pair per call +— including one that failed: + +``` + 1 +0.000s agent_start analyst + 2 +0.001s agent_start LangGraph · under analyst + 3 +0.002s hook_triggered agent + 4 +0.003s model_request gpt-5.6-terra + 5 +3.690s model_response gpt-5.6-terra · 99 out-tok + ... + 28 +5.830s tool_use restock_eta + 29 +5.834s tool_result restock_eta · error + ... + 36 +9.209s agent_end analyst · success +``` + +Note event 29: a failed tool is a `tool_result` carrying `error`. The run still +ends `success`, because the agent recovered — the trace preserves both facts. + +--- + +## Naming your agents + +By default the root span takes the graph's own name (`LangGraph`, or whatever +you passed to `.compile(name=...)`). Wrap it to get something meaningful: + +```python +with failproofai_sdk.session(): + with failproofai_sdk.agent("analyst", goal="price and stock report"): + graph.invoke(...) +``` + +Everything inside now carries `parent_id="analyst"`, and the dashboard groups by +`analyst` rather than by a framework class name. + +For multi-agent setups, nest the scopes — each worker becomes a child span: + +```python +with failproofai_sdk.session(): + with failproofai_sdk.agent("supervisor"): + with failproofai_sdk.agent("researcher"): + research_graph.invoke(...) + with failproofai_sdk.agent("writer"): + writer_graph.invoke(...) +``` + +See [`examples/supervisor_handoff.py`](examples/supervisor_handoff.py) — 38 +events, 5 agents, correctly nested. + +> **Keep `agent_id` low-cardinality.** It is a facet column. Use a role or node +> name (`analyst`, `researcher`), never a UUID or a per-run string. Put the real +> id in a payload field instead. + +--- + +## Controlling the session + +The session id is resolved in this order — first match wins: + +1. `failproofai_sdk.instrument("langchain", session_id="...")` — pins every run +2. `config={"metadata": {"failproofai_sdk_session_id": "..."}}` — per call +3. the ambient `failproofai_sdk.session()` / `agent()` scope +4. `metadata["session_id"]`, `metadata["conversation_id"]`, `metadata["thread_id"]` +5. the root run id + +It is **never synthesised from scratch**, because a made-up id splits one run +into many sessions — a silent wrong answer rather than a loud one. + +Per-call override: + +```python +graph.invoke( + {"messages": [...]}, + config={"metadata": {"failproofai_sdk_session_id": f"chat-{user_id}"}}, +) +``` + +> `thread_id` **is** available to callbacks on this stack, despite what you may +> have read. Reports that it is `None` are about `langchain-core` dropping +> `configurable` from `metadata`; LangGraph 1.2 re-adds it. It is still only the +> fourth resolution step, because a thread is a conversation, not necessarily a +> single run. + +--- + +## Options + +```python +failproofai_sdk.instrument( + "langchain", + session_id=None, # pin every run to one session id + include_chains=set(), # allowlist intermediate chains as hook pairs + capture_content=True, # False drops prompts and completions from payloads + graph_callbacks=True, # first-class interrupt/resume (needs langgraph >= 1.2) +) +``` + +**`capture_content=False`** is the switch for regulated data. Structure, +timings, token counts, tool names and outcomes are all still recorded; the +message bodies and completions are not. + +**`include_chains`** takes a set of runnable names. Use it sparingly — it exists +for a chain that is genuinely a step in your pipeline, not to surface +`RunnableSequence`. + +It applies to **nested** runs only. A runnable you invoke at the top level is +the session's root, so it becomes the agent span rather than a hook pair, and +naming it here has no effect. + +--- + +## What an event actually looks like + +A real `model_response`, captured from `examples/quickstart.py`: + +```json +{ + "timestamp": "2026-08-19T19:50:56.241974Z", + "session_id": "7e5de1571fa14424aa1a5bbd88cb420d", + "agent_id": "LangGraph", + "type": "model_response", + "environment": "production", + "model": "gpt-5.6-terra", + "stop_reason": "tool_calls", + "input_tokens": 139, + "output_tokens": 21, + "content": "", + "role": "assistant", + "request_id": "01a01b93-91a0-7c92-bf45-3857ee43d3ff", + "usage": { "input_tokens": 139, "output_tokens": 21, "total_tokens": 160 }, + "duration_ms": 5202, + "framework": "langchain", + "framework_version": "1.5.6", + "integration_version": "0.0.1b14", + "fw_langgraph_version": "1.2.11", + "fw_run_id": "01a01b93-91a0-7c92-bf45-3857ee43d3ff", + "fw_node": "agent", + "fw_step": 1 +} +``` + +Everything prefixed `fw_` is framework-specific detail, namespaced so it can +never collide with — and silently overwrite — a first-class field like +`model` or `output_tokens`. `request_id` is what pairs this response with its +request; without it the dashboard falls back to FIFO pairing and concurrent +calls mis-pair. + +--- + +## Human in the loop + +LangGraph's `interrupt()` produces **four** events, and neither pair is +redundant: + +```python +from langgraph.types import interrupt, Command + +def approve(state): + decision = interrupt({"prompt": "Ship it?", "options": ["yes", "no"]}) + return {"approved": decision == "yes"} + +with failproofai_sdk.session(): + graph.invoke(state, config) # human_wait + agent_pause + # ... minutes pass ... + graph.invoke(Command(resume="yes"), config) # agent_resume + human_input +``` + +- `human_wait` → `human_input` carries the prompt, the options, the answer and + the pending-human count; +- `agent_pause` → `agent_resume` is the **only** thing that feeds paused time. + Without it the whole human wait is billed as active agent time. + +The root agent span deliberately stays **open** across the gap, so the two +`.invoke()` calls are one session and one run. + +--- + +## Pitfalls + +### A raising tool aborts the whole graph + +**Symptom:** your run dies on the first tool exception instead of recovering. + +**Cause:** `create_react_agent` propagates it by default. + +**Fix:** build the tool node explicitly. + +```python +from langgraph.prebuilt import ToolNode, create_react_agent + +tools = ToolNode([price_of, stock_of], handle_tool_errors=True) +graph = create_react_agent(model, tools) +``` + +The failure is recorded as `tool_result` with `error` either way — this only +decides whether the run survives it. + +--- + +### A bare `llm.invoke()` shows up as an agent named after the model class + +**Symptom:** an `agent_id` of `ChatOpenAI` in your trace. + +**Cause:** a direct model call outside any graph has no parent run, so it opens +a root agent span **and** emits its own `model_request` / `model_response` pair +inside it. The dashboard parents leaves to an open agent and synthesises a +never-ending root span when there is none, so the span is deliberate. + +**Fix:** name it. + +```python +with failproofai_sdk.agent("summariser"): + summary = ChatOpenAI(model="gpt-4o-mini").invoke([HumanMessage(text)]) +``` + +> Before v1.0.1-beta.2 this case emitted the agent span and **nothing else** — +> no model name, no token counts, no latency. If you are on an older build, +> upgrade. + +--- + +### Interrupts look like errors and are not + +**Symptom:** you expect a red error on every human approval. + +**Cause:** LangGraph's runnable does `except BaseException: on_chain_error(e); +raise` with no special case for interrupts, so every HITL pause reaches the +tracer as an error callback. + +**Fix:** none needed. Any `GraphBubbleUp` subclass — `GraphInterrupt`, +`NodeInterrupt`, `ParentCommand`, `GraphDrained` — is treated as control flow. +Without that, every approval would paint a red error plus +`agent_end(outcome="failed")`. + +--- + +### Everything is recorded twice + +**Symptom:** every event appears two times. + +**Cause:** you passed a Failproof handler in `config={"callbacks": [...]}` *as +well as* calling `instrument()`. + +**Fix:** remove it. The configure hook already covers every callback manager in +the process. There is never a reason to register a handler by hand. + +--- + +### Nothing is recorded at all + +Work through these in order: + +1. Did `instrument()` actually run before the graph executed? +2. Is there a `with failproofai_sdk.session():` around the call? Without one, + every `.invoke()` becomes its own session — you will have events, but + scattered. +3. Set `FAILPROOFAI_SDK_STRICT=1` and run again. A silently degraded hook will + now raise instead of being swallowed. +4. Are you reading the spool directory to check? Do not — a running + `failproofaid` deletes each batch within milliseconds. Check the dashboard. + +--- + +### Events from a worker thread raise `TypeError` + +**Symptom:** `TypeError` naming `session_id` from inside a thread pool. + +**Cause:** contextvars propagate into asyncio tasks automatically but **not** +into new threads — a thread starts with an empty context. + +**Fix:** + +```python +pool.submit(failproofai_sdk.propagate(work), x) +threading.Thread(target=failproofai_sdk.propagate(work)).start() +``` + +--- + +## Runnable examples + +| file | what it shows | events | +|---|---|---| +| [`examples/quickstart.py`](examples/quickstart.py) | one tool, one turn — the smallest real trace | 14 | +| [`examples/research_agent.py`](examples/research_agent.py) | 3 tools, several turns, one tool that fails and recovers | 36 | +| [`examples/supervisor_handoff.py`](examples/supervisor_handoff.py) | a supervisor delegating to 2 workers, nested spans | 38 | + +```bash +export OPENAI_API_KEY=sk-... +export FPAI_MODEL=gpt-4o-mini +python docs/langgraph/examples/quickstart.py +``` + +Each prints the event stream it produced. All three were run against a live +model before shipping. diff --git a/sdk/python/docs/langgraph/examples/quickstart.py b/sdk/python/docs/langgraph/examples/quickstart.py new file mode 100644 index 000000000..7c4e23f6e --- /dev/null +++ b/sdk/python/docs/langgraph/examples/quickstart.py @@ -0,0 +1,51 @@ +"""langgraph — the smallest thing that produces a trace. + + pip install 'failproofai-sdk[langgraph]' + python docs/langgraph/examples/quickstart.py + +`instrument()` installs a tracer on langchain's global callback manager, so +every graph, node, tool and model call in the process is recorded — including +ones inside libraries you did not write. Nothing below passes an id by hand. +""" +import sys +from pathlib import Path + +# Only so this file can import the shared trace printer from docs/_shared/. +# Delete these two lines and the `_shared` import below and the example still +# instruments correctly — it just stops printing its own trace at the end. +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +import failproofai_sdk +from _shared import banner, model, trace +from langchain_core.messages import HumanMessage +from langchain_core.tools import tool +from langchain_openai import ChatOpenAI +from langgraph.prebuilt import create_react_agent + +failproofai_sdk.configure(environment="examples") +failproofai_sdk.instrument() + + +@tool +def word_count(text: str) -> int: + """Count the words in a piece of text.""" + return len(text.split()) + + +def main() -> None: + banner("langgraph quickstart", "one tool, one turn") + graph = create_react_agent(ChatOpenAI(model=model()), [word_count]) + + # A session groups everything below into one run. Without it each + # `.invoke()` becomes its own session, which is rarely what you want. + with failproofai_sdk.session() as sid: + result = graph.invoke( + {"messages": [HumanMessage("How many words in 'hello brave new world'?")]} + ) + + print(" answer:", result["messages"][-1].content.strip()[:120]) + trace(sid, title="langgraph quickstart") + + +if __name__ == "__main__": + main() diff --git a/sdk/python/docs/langgraph/examples/research_agent.py b/sdk/python/docs/langgraph/examples/research_agent.py new file mode 100644 index 000000000..e566ca41b --- /dev/null +++ b/sdk/python/docs/langgraph/examples/research_agent.py @@ -0,0 +1,86 @@ +"""langgraph — a real multi-step run: three tools, several turns. + + pip install 'failproofai-sdk[langgraph]' + python docs/langgraph/examples/research_agent.py + +What this demonstrates that the quickstart does not: + +* several model turns in one session, each paired `model_request`/`model_response` + with real token counts and latency; +* three distinct tools, each a `tool_use`/`tool_result` pair carrying arguments + and output; +* a tool that raises — recorded as `tool_result` with `error` set, and the run + keeps going, because a failed tool is data, not a crash; +* a named `agent()` scope, so the trace is labelled `analyst` rather than the + graph's generated id. +""" +import sys +from pathlib import Path + +# Only so this file can import the shared trace printer from docs/_shared/. +# Delete these two lines and the `_shared` import below and the example still +# instruments correctly — it just stops printing its own trace at the end. +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +import failproofai_sdk +from _shared import banner, model, trace +from langchain_core.messages import HumanMessage +from langchain_core.tools import tool +from langchain_openai import ChatOpenAI +from langgraph.prebuilt import ToolNode, create_react_agent + +failproofai_sdk.configure(environment="examples") +failproofai_sdk.instrument() + +_PRICES = {"widget": 42.0, "gadget": 17.5, "sprocket": 3.25} +_STOCK = {"widget": 120, "gadget": 0, "sprocket": 8} + + +@tool +def price_of(item: str) -> float: + """Return the unit price of an item in USD.""" + return _PRICES[item.lower().strip()] + + +@tool +def stock_of(item: str) -> int: + """Return the units of an item currently in stock.""" + return _STOCK[item.lower().strip()] + + +@tool +def restock_eta(item: str) -> str: + """Return the restock ETA. Raises for items that are not tracked.""" + # Deliberately unhandled: the trace should show a failed tool call and the + # agent recovering from it, which is the interesting case to observe. + raise LookupError(f"no restock schedule for {item!r}") + + +def main() -> None: + banner("langgraph research agent", "3 tools · multi-turn · one failing call") + + # `handle_tool_errors=True` feeds the exception back to the model as a tool + # message instead of raising out of `.invoke()`. The failproof trace records + # the failure either way — this just lets the run continue so you can watch + # the model recover from it. + tools = ToolNode([price_of, stock_of, restock_eta], handle_tool_errors=True) + graph = create_react_agent(ChatOpenAI(model=model()), tools) + + question = ( + "For 'widget' and 'gadget': give me the unit price and the stock level. " + "For anything out of stock, try to look up the restock ETA. " + "Finish with a two-line summary." + ) + + # `agent("analyst")` names the span. Without it the adapter falls back to the + # graph's own label, which is stable but not meaningful on a dashboard. + with failproofai_sdk.session() as sid: + with failproofai_sdk.agent("analyst", goal="price and stock report"): + result = graph.invoke({"messages": [HumanMessage(question)]}) + + print(" answer:", result["messages"][-1].content.strip()[:200], "\n") + trace(sid, title="langgraph research agent") + + +if __name__ == "__main__": + main() diff --git a/sdk/python/docs/langgraph/examples/supervisor_handoff.py b/sdk/python/docs/langgraph/examples/supervisor_handoff.py new file mode 100644 index 000000000..be873cb58 --- /dev/null +++ b/sdk/python/docs/langgraph/examples/supervisor_handoff.py @@ -0,0 +1,83 @@ +"""langgraph — a supervisor delegating to two workers. + + pip install 'failproofai-sdk[langgraph]' + python docs/langgraph/examples/supervisor_handoff.py + +Multi-agent is where a flat event log stops being readable, so this is the +example to run if you want to see what the parent/child structure buys you. + +Each worker runs inside its own `agent()` scope nested in the supervisor's, so +every event a worker emits carries `parent_id="supervisor"` and `depth=2`. The +dashboard renders that as a tree; a flat log cannot tell you which agent made +which model call. +""" +import sys +from pathlib import Path + +# Only so this file can import the shared trace printer from docs/_shared/. +# Delete these two lines and the `_shared` import below and the example still +# instruments correctly — it just stops printing its own trace at the end. +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +import failproofai_sdk +from _shared import banner, model, trace +from langchain_core.messages import HumanMessage +from langchain_core.tools import tool +from langchain_openai import ChatOpenAI +from langgraph.prebuilt import create_react_agent + +failproofai_sdk.configure(environment="examples") +failproofai_sdk.instrument() + + +@tool +def fetch_incidents(service: str) -> str: + """List recent incidents for a service.""" + return { + "checkout": "2 incidents: 503 spike (12m), payment timeout (4m)", + "search": "1 incident: index lag (31m)", + }.get(service, "no incidents on record") + + +@tool +def oncall_for(service: str) -> str: + """Return who is on call for a service.""" + return {"checkout": "dana", "search": "kim"}.get(service, "unassigned") + + +def worker(name: str, tools: list, prompt: str) -> str: + """One delegated unit of work, bracketed as its own child agent.""" + graph = create_react_agent(ChatOpenAI(model=model()), tools) + with failproofai_sdk.agent(name, goal=prompt[:60]): + out = graph.invoke({"messages": [HumanMessage(prompt)]}) + return out["messages"][-1].content + + +def main() -> None: + banner("langgraph supervisor handoff", "1 supervisor → 2 workers · nested spans") + + with failproofai_sdk.session() as sid: + with failproofai_sdk.agent("supervisor", goal="incident brief for checkout"): + incidents = worker( + "incident_worker", + [fetch_incidents], + "Use the tool to list recent incidents for the 'checkout' service.", + ) + owner = worker( + "roster_worker", + [oncall_for], + "Use the tool to say who is on call for the 'checkout' service.", + ) + + # The supervisor's own model turn, at depth 1 — the two worker turns + # above are at depth 2 and carry parent_id="supervisor". + summary = ChatOpenAI(model=model()).invoke( + [HumanMessage(f"In two lines, brief the on-call.\n{incidents}\n{owner}")] + ) + + print(" brief:", summary.content.strip()[:200], "\n") + trace(sid, title="langgraph supervisor handoff") + + +if __name__ == "__main__": + main() diff --git a/sdk/python/docs/llama_index/README.md b/sdk/python/docs/llama_index/README.md new file mode 100644 index 000000000..138b2ddc1 --- /dev/null +++ b/sdk/python/docs/llama_index/README.md @@ -0,0 +1,332 @@ +# LlamaIndex + +- [Install](#install) +- [The integration](#the-integration) +- [Read this first: token counts](#read-this-first-token-counts) +- [How it attaches](#how-it-attaches) +- [What gets recorded](#what-gets-recorded) +- [A complete example](#a-complete-example) +- [Options](#options) +- [Human in the loop](#human-in-the-loop) +- [Pitfalls](#pitfalls) +- [Runnable examples](#runnable-examples) + +--- + +## Install + +```bash +pip install 'failproofai-sdk[llamaindex]' # pins llama-index-core >=0.14.23,<0.15 +``` + +**Supported:** `llama-index-core` 0.14.23 → 0.15. 0.14.23 is a capability floor: +it is the release where `to_payload()` replaced `to_dict()` and where the +workflow stream started carrying the typed agent events this adapter reads. +Below it, model names and agent structure both go missing. + +--- + +## The integration + +```python +import asyncio +import failproofai_sdk + +failproofai_sdk.configure(environment="production") +failproofai_sdk.instrument() + +async def main(): + async with failproofai_sdk.session(): + await agent.run("...") + +asyncio.run(main()) +``` + +LlamaIndex's agent API is async. Every Failproof scope works under `async with` +as well as `with`, and produces byte-identical events either way. + +--- + +## Read this first: token counts + +**Without one extra argument, every token count in your trace will be null.** + +`FunctionAgent` — the agent API LlamaIndex documents — calls `astream_chat`. And +`llama-index-llms-openai` does not send `stream_options={"include_usage": True}` +when it streams, so **the provider never sends the usage chunk at all**. +`LLMChatEndEvent.response.raw` arrives with no `usage` key on it, and there is +nothing for any instrumentation to read. + +This is upstream LlamaIndex behaviour, not a Failproof one. Verified by spying +on the dispatcher directly against `llama-index-core` 0.14.23: every single +`LLMChatEndEvent` in a `FunctionAgent` run has usage absent. + +**The fix is one argument on your LLM:** + +```python +from llama_index.llms.openai import OpenAI + +llm = OpenAI( + model="gpt-4o-mini", + additional_kwargs={"stream_options": {"include_usage": True}}, +) +``` + +Measured on the same run, same model: + +| | `input_tokens` | `output_tokens` | +|---|---|---| +| without | `None` | `None` | +| with | `148` | `17` | + +Non-streaming calls (`llm.chat`, `llm.achat`) extract usage correctly with no +configuration at all. It is only the streaming path — which is the default agent +path — that needs this. + +--- + +## How it attaches + +Two handlers on LlamaIndex's global dispatcher: + +- an **event handler** for the typed workflow and LLM events, and +- a **span handler** for the enter/exit spans around workflows, steps and tools. + +Both together are what makes the agent *loop* visible, not just its model calls. +This is the main thing this adapter buys you over a model-only integration: you +can see `parse_agent_output` taking 160ms, or `aggregate_tool_results` running +twice. + +--- + +## What gets recorded + +| LlamaIndex | Failproof event | When | +|---|---|---| +| `Workflow.run` root span | session + `agent_start` / `agent_end` | the outermost run | +| nested `Workflow.run` span | nested `agent_start` / `agent_end` | a sub-workflow | +| workflow step span | `hook_triggered` / `hook_completed` | each step in the agent loop | +| `LLMChatStartEvent` / `EndEvent` | `model_request` / `model_response` | each model call | +| `FunctionTool.call` span | `tool_use` / `tool_result` | each tool call | +| `RetrievalStartEvent` / `EndEvent` | `tool_use` / `tool_result` | output summarised, not dumped | +| embeddings | *nothing* | unless `embeddings=True` | +| `WaitingForEvent` drop | `human_wait` + `agent_pause` | a tool waits on a person | +| exception | `error` + `agent_end(outcome="failed")` | the run raises | + +`agent_id` is the `FunctionAgent.name` when you set one, and the workflow class +name otherwise — never a span id. + +```python +FunctionAgent(name="city_analyst", tools=[...], llm=llm) # agent_id = "city_analyst" +``` + +### Why retrieval output is summarised + +A retriever returns documents. Dumping them into the payload puts your entire +corpus in the events store, one copy per query. The adapter records the count, +the score range and truncated snippets instead. If you need the documents +themselves, they are in your index. + +### Why steps are hooks and not agents + +`init_run`, `setup_agent`, `run_agent_step`, `parse_agent_output`, `call_tool` +and `aggregate_tool_results` are the framework's own loop, not units of work you +wrote. They are hook pairs so `agent_id` stays a meaningful facet instead of +filling up with machinery. + +--- + +## A complete example + +```python +import asyncio + +import failproofai_sdk +from llama_index.core.agent.workflow import FunctionAgent +from llama_index.core.tools import FunctionTool +from llama_index.llms.openai import OpenAI + +failproofai_sdk.configure(environment="production") +failproofai_sdk.instrument() + +POP = {"tokyo": "37M", "delhi": "33M", "lagos": "16M"} +AREA = {"tokyo": "2,194 km2", "delhi": "1,484 km2", "lagos": "1,171 km2"} + + +def population(city: str) -> str: + """Population of a city. Valid: tokyo, delhi, lagos.""" + return POP.get(city.lower().strip(), "unknown") + + +def area(city: str) -> str: + """Land area of a city. Valid: tokyo, delhi, lagos.""" + return AREA.get(city.lower().strip(), "unknown") + + +async def main(): + agent = FunctionAgent( + name="city_analyst", + tools=[ + FunctionTool.from_defaults(fn=population), + FunctionTool.from_defaults(fn=area), + ], + llm=OpenAI( + model="gpt-4o-mini", + # REQUIRED for token counts on the streaming agent path. + additional_kwargs={"stream_options": {"include_usage": True}}, + ), + system_prompt="Use the tools. Be terse.", + ) + + async with failproofai_sdk.session(): + async with failproofai_sdk.agent("city_analyst", goal="compare two cities"): + answer = await agent.run("Compare Tokyo and Delhi on population and area.") + + print(answer) + + +asyncio.run(main()) +``` + +The quickstart version of this produces **26 events across 8 types**, and the +agent loop is fully visible: + +``` + 1 +0.000s agent_start Agent + 2 +0.001s hook_triggered init_run + 4 +0.548s hook_triggered setup_agent + 6 +0.549s hook_triggered run_agent_step + 7 +0.551s model_request gpt-5.6-terra + 8 +3.546s model_response gpt-5.6-terra + 10 +3.662s hook_triggered parse_agent_output + 12 +3.825s hook_triggered call_tool + 13 +3.825s tool_use city_population + 14 +3.826s tool_result city_population · ok + 16 +3.826s hook_triggered aggregate_tool_results + ... + 26 +6.968s agent_end Agent · success +``` + +--- + +## Options + +```python +failproofai_sdk.instrument( + "llama_index", + embeddings=False, # True records embedding calls as tool pairs + steps=True, # False drops workflow-step hook pairs + capture_messages=True, # False drops prompts and system text from payloads + stale_after=600.0, # seconds before an abandoned span is force-closed + reaper_interval=30.0, # how often the reaper sweeps; 0 disables it +) +``` + +| Option | Why you would change it | +|---|---| +| `embeddings` | Off by default: a bulk index build is thousands of calls and buries the timeline. On for debugging embedding latency or cost. | +| `steps` | Off if you want only model and tool events and find the agent loop noisy. | +| `capture_messages` | Off for regulated data. Structure, timings, tokens and outcomes are still recorded. | +| `stale_after` | A workflow that never finishes leaves an open span. The reaper force-closes it after this many seconds, so the session settles instead of reading `ongoing` forever. | +| `reaper_interval` | Sweep frequency. `0` disables the reaper. | + +**There is no `session_id` option on this adapter, and no `capture_content`.** +Set the session with a scope, and use `capture_messages` for content: + +```python +async with failproofai_sdk.session(f"chat-{user_id}"): + await agent.run(...) +``` + +--- + +## Human in the loop + +Only visible when the wait happens **inside a tool**. + +```python +async def ask_human(question: str) -> str: + """Ask a person and wait for their answer.""" + ctx = ... # your workflow context + response = await ctx.wait_for_event(HumanResponseEvent) + return response.answer +``` + +That produces `human_wait` + `agent_pause`, then `agent_resume` + +`human_input` on the retry. + +`ctx.wait_for_event` in a **plain workflow step** is not captured. The runtime +catches the drop before it reaches the dispatcher: the step simply exits with +`None` and re-runs later, so there is no signal to key a pause on. The +FunctionAgent pattern — which is the one LlamaIndex documents — waits inside a +tool and is captured in full. + +--- + +## Pitfalls + +### Every token count is null + +See [Read this first](#read-this-first-token-counts). One argument on your LLM. + +--- + +### Token counts are null on a non-OpenAI integration + +**Symptom:** `usage` is populated in the payload but `input_tokens` / +`output_tokens` are `None`. + +**Cause:** there is no standard usage field in LlamaIndex. The adapter tries +`response.raw["usage"]`, then `raw["usage_metadata"]`, then +`response.additional_kwargs`, calling `model_dump()` first when `raw` is a +pydantic model. An integration that names its counters something new will not +match. + +**Why it behaves this way:** the top-level token fields are set **only** when a +recognised key is present, but the raw dict always ships as `usage` regardless. +A populated `usage` with blank token columns is the honest outcome, and much +better than a confident wrong number. Look at `usage` in the payload to see what +your provider actually called them, and open an issue with that key name. + +--- + +### The timeline is buried in `init_run` / `setup_agent` noise + +**Symptom:** far more hook pairs than you expected. + +**Cause:** that is the FunctionAgent loop. Every iteration is +`setup_agent` → `run_agent_step` → `parse_agent_output` → +`call_tool` → `aggregate_tool_results`. + +**Fix:** nothing is wrong, but you can filter by `hook_name` on the dashboard. +The step timings are usually the reason people install this adapter rather than +a model-only one. + +--- + +### Nothing is recorded at all + +1. Did `instrument()` run before you constructed the agent? (For LlamaIndex the + order does not matter, unlike Pydantic AI — but check anyway.) +2. Is there an `async with failproofai_sdk.session():` around the `await`? +3. Set `FAILPROOFAI_SDK_STRICT=1` and run again. +4. Check `llama-index-core >= 0.14.23`. + +--- + +## Runnable examples + +| file | what it shows | events | +|---|---|---| +| [`examples/quickstart.py`](examples/quickstart.py) | one tool, one turn, async | 26 | +| [`examples/research_agent.py`](examples/research_agent.py) | 3 tools, several turns, one tool that fails | 64 | + +```bash +export OPENAI_API_KEY=sk-... +export OPENAI_API_BASE=... # note: llama_index reads API_BASE, not BASE_URL +export FPAI_MODEL=gpt-4o-mini +python docs/llama_index/examples/quickstart.py +``` + +Both were run against a live model before shipping. Both set `stream_options`, +so both report real token counts. diff --git a/sdk/python/docs/llama_index/examples/quickstart.py b/sdk/python/docs/llama_index/examples/quickstart.py new file mode 100644 index 000000000..4a31370c2 --- /dev/null +++ b/sdk/python/docs/llama_index/examples/quickstart.py @@ -0,0 +1,62 @@ +"""llama_index — the smallest thing that produces a trace. + + pip install 'failproofai-sdk[llamaindex]' + python docs/llama_index/examples/quickstart.py + +`instrument()` attaches an event handler and a span handler to llama_index's +global dispatcher, so workflow steps arrive as hook pairs and llm calls as +`model_request`/`model_response`. + +The scopes work under `async with` as well as `with`, which matters here because +llama_index's agent api is async. +""" +import asyncio +import sys +from pathlib import Path + +# Only so this file can import the shared trace printer from docs/_shared/. +# Delete these two lines and the `_shared` import below and the example still +# instruments correctly — it just stops printing its own trace at the end. +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +import failproofai_sdk +from _shared import banner, model, trace +from llama_index.core.agent.workflow import FunctionAgent +from llama_index.core.tools import FunctionTool +from llama_index.llms.openai import OpenAI + +failproofai_sdk.configure(environment="examples") +failproofai_sdk.instrument() + + +def city_population(city: str) -> str: + """Population of a city.""" + return {"tokyo": "37M", "delhi": "33M"}.get(city.lower(), "unknown") + + +async def main() -> None: + banner("llama_index quickstart", "one tool, one turn, async") + + agent = FunctionAgent( + tools=[FunctionTool.from_defaults(fn=city_population)], + llm=OpenAI( + model=model(), + # WITHOUT THIS, EVERY TOKEN COUNT IS NULL. + # FunctionAgent calls `astream_chat`, and llama_index does not ask + # OpenAI for usage on a stream — so `LLMChatEndEvent` carries a + # response with no usage on it and there is nothing for any + # instrumentation to read. This is upstream behaviour, not a + # failproof one; the line below is the documented way back. + additional_kwargs={"stream_options": {"include_usage": True}}, + ), + system_prompt="Answer using the tool. Be terse.", + ) + async with failproofai_sdk.session() as sid: + answer = await agent.run("What is the population of Tokyo?") + + print(" answer:", str(answer).strip()[:120]) + trace(sid, title="llama_index quickstart") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/python/docs/llama_index/examples/research_agent.py b/sdk/python/docs/llama_index/examples/research_agent.py new file mode 100644 index 000000000..2d50f4fe8 --- /dev/null +++ b/sdk/python/docs/llama_index/examples/research_agent.py @@ -0,0 +1,93 @@ +"""llama_index — a multi-tool async agent, including a tool that fails. + + pip install 'failproofai-sdk[llamaindex]' + python docs/llama_index/examples/research_agent.py + +What this demonstrates that the quickstart does not: + +* three tools across several turns, each a `tool_use`/`tool_result` pair; +* a tool that raises, recorded as `tool_result` with `error` set while the run + continues; +* llama_index workflow steps as `hook_triggered`/`hook_completed`, so you can + see the agent loop itself and not just its llm calls; +* a named `agent()` scope wrapping an async run — the scopes support + `async with`, so nothing about identity changes when the framework does. +""" +import asyncio +import sys +from pathlib import Path + +# Only so this file can import the shared trace printer from docs/_shared/. +# Delete these two lines and the `_shared` import below and the example still +# instruments correctly — it just stops printing its own trace at the end. +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +import failproofai_sdk +from _shared import banner, model, trace +from llama_index.core.agent.workflow import FunctionAgent +from llama_index.core.tools import FunctionTool +from llama_index.llms.openai import OpenAI + +failproofai_sdk.configure(environment="examples") +failproofai_sdk.instrument() + +_POP = {"tokyo": "37M", "delhi": "33M", "lagos": "16M"} +_AREA = {"tokyo": "2,194 km2", "delhi": "1,484 km2", "lagos": "1,171 km2"} + + +def population(city: str) -> str: + """Population of a city. Valid: tokyo, delhi, lagos.""" + return _POP.get(city.lower().strip(), "unknown") + + +def area(city: str) -> str: + """Land area of a city. Valid: tokyo, delhi, lagos.""" + return _AREA.get(city.lower().strip(), "unknown") + + +def founding_year(city: str) -> str: + """Founding year of a city. Not actually available for any of them.""" + # Deliberately raises: the trace should show a failed tool and the agent + # carrying on without it. + raise LookupError(f"no founding-year record for {city!r}") + + +async def main() -> None: + banner("llama_index research agent", "3 tools · multi-turn · one failing call") + + agent = FunctionAgent( + tools=[ + FunctionTool.from_defaults(fn=population), + FunctionTool.from_defaults(fn=area), + FunctionTool.from_defaults(fn=founding_year), + ], + llm=OpenAI( + model=model(), + # WITHOUT THIS, EVERY TOKEN COUNT IS NULL. + # FunctionAgent calls `astream_chat`, and llama_index does not ask + # OpenAI for usage on a stream — so `LLMChatEndEvent` carries a + # response with no usage on it and there is nothing for any + # instrumentation to read. This is upstream behaviour, not a + # failproof one; the line below is the documented way back. + additional_kwargs={"stream_options": {"include_usage": True}}, + ), + system_prompt=( + "Use the tools. If a tool fails, say so and continue with what you have." + ), + ) + + question = ( + "Compare Tokyo and Delhi on population and area, then try to get the " + "founding year for each. Finish with a two-line comparison." + ) + + async with failproofai_sdk.session() as sid: + async with failproofai_sdk.agent("city_analyst", goal="compare two cities"): + answer = await agent.run(question) + + print(" answer:", str(answer).strip()[:220], "\n") + trace(sid, title="llama_index research agent") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/python/docs/manual/README.md b/sdk/python/docs/manual/README.md new file mode 100644 index 000000000..fe10aaf13 --- /dev/null +++ b/sdk/python/docs/manual/README.md @@ -0,0 +1,491 @@ +# Your own agent — no framework + +For an agent you wrote yourself, or a framework Failproof has no adapter for. +There is nothing to instrument: you emit the events. + +This is a first-class path, not a consolation prize. It is exactly what the four +adapters call underneath — they are translation tables over the same API. + +- [Install](#install) +- [The three scopes](#the-three-scopes) +- [What a scope does on the way out](#what-a-scope-does-on-the-way-out) +- [The fifteen event methods](#the-fifteen-event-methods) +- [A complete example](#a-complete-example) +- [Threads and async](#threads-and-async) +- [Instrumenting an unsupported framework](#instrumenting-an-unsupported-framework) +- [Why there is no AutoGen adapter](#why-there-is-no-autogen-adapter) +- [Pitfalls](#pitfalls) +- [Runnable examples](#runnable-examples) + +--- + +## Install + +```bash +pip install failproofai-sdk +``` + +No extras. Zero dependencies. + +--- + +## The three scopes + +```python +import failproofai_sdk + +failproofai_sdk.configure(environment="production") + +with failproofai_sdk.session() as sid: # identity only — emits nothing + with failproofai_sdk.agent("planner"): # agent_start / agent_end + with failproofai_sdk.tool_call("search", input={"q": q}) as t: + t.output = search(q) # tool_use / tool_result +``` + +| scope | emits | purpose | +|---|---|---| +| `session()` | nothing | binds a session id — groups one run | +| `agent()` | `agent_start` / `agent_end` | brackets a unit of work | +| `tool_call()` | `tool_use` / `tool_result` | brackets one tool, measuring it | + +**Everything inside them can omit `session_id=` and `agent_id=`.** The scopes +bind identity on contextvars and every `event.*` call reads it back. That is the +whole point — threading two ids through every function that might emit an event +is what turns instrumentation into a diff nobody wants to review. + +All three work under `async with` as well as `with`: + +```python +async with failproofai_sdk.session(): + async with failproofai_sdk.agent("planner"): + async with failproofai_sdk.tool_call("search", input={"q": q}) as t: + t.output = await search(q) +``` + +### Nesting agents + +```python +with failproofai_sdk.session(): + with failproofai_sdk.agent("supervisor"): + with failproofai_sdk.agent("researcher"): # parent_id = "supervisor" + ... + with failproofai_sdk.agent("writer"): # parent_id = "supervisor" + ... +``` + +`parent_id` and `depth` are computed from the stack. You never pass them. + +--- + +## What a scope does on the way out + +`agent()` handles exceptions for you: + +| what happened | events emitted | `outcome` | +|---|---|---| +| nothing raised | `agent_end` | `success` | +| `Exception` | `error`, then `agent_end` | `failed` | +| `KeyboardInterrupt` / `SystemExit` | `error`, then `agent_end` | `failed` | +| `CancelledError` / `GeneratorExit` | `agent_end` only | `cancelled` | + +Two deliberate details: + +- **`error` strictly before `agent_end`.** The dashboard closes the span at + `agent_end`, so anything emitted after it is attributed to nothing. +- **A cancellation is not a failure.** Otherwise every cancelled run pollutes + the errors surface. `asyncio.CancelledError` has been a `BaseException` since + Python 3.8, and it is caught and re-raised untouched. + +The exception is **always** re-raised. A scope never swallows. + +`tool_call()` does the same for tools: a raising body produces +`tool_result(error="TypeName: msg")` and **no** `error` event, because a tool +failure the agent loop catches is not a run-level error, and one that propagates +is reported exactly once by the enclosing `agent()`. + +--- + +## The fifteen event methods + +### agents + +```python +failproofai_sdk.event.agent_start(agent_id="planner", goal="find the cheapest flight") +failproofai_sdk.event.agent_end(agent_id="planner", outcome="success", summary="...") +failproofai_sdk.event.agent_pause(pause_id="p1", reason="awaiting approval") +failproofai_sdk.event.agent_resume(pause_id="p1") +``` + +`outcome` is `"failed"`, never `"failure"` — only `error|failed|timeout|rejected` +count as a failure server-side. + +### models + +```python +failproofai_sdk.event.model_request( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "..."}], + request_id="req-1", # optional; pairs the two events explicitly +) +failproofai_sdk.event.model_response( + model="gpt-4o-mini", + response="...", + input_tokens=139, + output_tokens=21, + request_id="req-1", + duration_ms=5202, # int, never float +) +``` + +Pass `request_id` if you make concurrent model calls — without it the dashboard +pairs requests and responses FIFO per agent, and concurrent calls mis-pair. + +### tools + +```python +failproofai_sdk.event.tool_use(tool_name="search", tool_call_id="c1", input={"q": "..."}) +failproofai_sdk.event.tool_result(tool_name="search", tool_call_id="c1", output="...", error=None) +``` + +`tool_call_id` is what pairs them. Prefer the `tool_call()` scope, which +guarantees the pair even when the body raises. + +### hooks + +```python +failproofai_sdk.event.hook_triggered(hook_name="retrieve", hook_id="h1", trigger_event="node") +failproofai_sdk.event.hook_completed(hook_name="retrieve", hook_id="h1", outcome="success") +``` + +Use these for node, step or middleware boundaries — **not** nested `agent()` +calls. `agent_id` is a low-cardinality facet, and one entry per node drowns it. + +### humans + +```python +failproofai_sdk.event.human_wait(input_id="i1", prompt="Approve?", options=["yes", "no"]) +failproofai_sdk.event.human_input(input_id="i1", response="yes") + +failproofai_sdk.event.human_pause(reason="operator paused the run", user_id="dana") +failproofai_sdk.event.human_interrupt(reason="operator stopped the run", at_step="step_3") +``` + +`human_wait`/`human_input` = *the agent asked a person*. +`human_pause`/`human_interrupt` = *a person acted on the agent* — a stop button, +an operator intervention. No framework signals the second pair, so it is always +yours. + +Emit `agent_pause`/`agent_resume` around a human wait too: only that pair feeds +paused time, and without it the wait is billed as active agent time. + +### failures + +```python +failproofai_sdk.event.error( + error_type="TimeoutError", + message="provider timed out after 30s", + traceback="...", +) +``` + +Usually emitted for you by `agent()`. Call it directly for a failure that does +not raise — a validation rejection, a guardrail trip. + +--- + +## A complete example + +A real tool-calling loop against the OpenAI API, with no agent framework at all: + +```python +import json + +import failproofai_sdk +from openai import OpenAI + +failproofai_sdk.configure(environment="production") + +client = OpenAI() +MODEL = "gpt-4o-mini" + +PRICE = {"widget": 42.0, "gadget": 17.5} +STOCK = {"widget": 120, "gadget": 0} + +TOOLS = [ + { + "type": "function", + "function": { + "name": "price_of", + "description": "Unit price of an item.", + "parameters": { + "type": "object", + "properties": {"item": {"type": "string"}}, + "required": ["item"], + }, + }, + }, +] + + +def run_tool(name: str, args: dict) -> str: + if name == "price_of": + return str(PRICE[args["item"].lower().strip()]) + raise LookupError(f"unknown tool {name!r}") + + +def turn(messages: list): + """One LLM call, bracketed by the model pair.""" + failproofai_sdk.event.model_request(model=MODEL, messages=messages) + reply = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOLS) + usage = reply.usage + failproofai_sdk.event.model_response( + model=MODEL, + response=reply.choices[0].message.content or "", + input_tokens=usage.prompt_tokens, + output_tokens=usage.completion_tokens, + ) + return reply.choices[0].message + + +messages = [ + {"role": "system", "content": "Use the tools for every number. Be terse."}, + {"role": "user", "content": "Price for widget and gadget?"}, +] + +with failproofai_sdk.session(): + with failproofai_sdk.agent("inventory", goal="price report"): + for _ in range(4): # bounded: an unbounded agent loop is its own bug + message = turn(messages) + calls = message.tool_calls or [] + if not calls: + print(message.content) + break + + messages.append(message.model_dump(exclude_none=True)) + for call in calls: + args = json.loads(call.function.arguments or "{}") + # tool_call() emits tool_use now and tool_result on exit, + # turning a raised exception into `error` on the result. + with failproofai_sdk.tool_call( + call.function.name, tool_call_id=call.id, input=args + ) as handle: + handle.output = run_tool(call.function.name, args) + messages.append({ + "role": "tool", + "tool_call_id": call.id, + "content": str(handle.output), + }) +``` + +Produces **14 events across 6 types** — the same shape an adapter would give +you: + +``` + 1 +0.000s agent_start inventory + 2 +0.000s model_request gpt-5.6-terra + 3 +3.962s model_response gpt-5.6-terra · 96 out-tok + 4 +3.962s tool_use price_of + 5 +3.962s tool_result price_of · ok + ... + 12 +3.962s model_request gpt-5.6-terra + 13 +8.459s model_response gpt-5.6-terra · 29 out-tok + 14 +8.459s agent_end inventory · success +``` + +--- + +## Threads and async + +contextvars propagate into asyncio tasks **automatically**. They do **not** +propagate into new threads — a thread starts with an empty context. + +```python +import failproofai_sdk + +# asyncio: nothing to do +async with failproofai_sdk.session(): + await asyncio.gather(worker(1), worker(2)) # both see the session + +# threads: wrap the callable +pool.submit(failproofai_sdk.propagate(work), x) +pool.map(failproofai_sdk.propagate(work), items) +threading.Thread(target=failproofai_sdk.propagate(work)).start() +loop.run_in_executor(None, failproofai_sdk.propagate(work), x) +``` + +Without `propagate()`, the worker's events raise a `TypeError` naming the fix, +rather than silently landing on no session. + +`propagate()` snapshots identity *values*, not the `Context` object — a +`Context` cannot be entered by two threads at once, so the obvious +`copy_context().run` form crashes on any reuse such as `pool.map`. + +--- + +## Instrumenting an unsupported framework + +Most agent frameworks give you three seams. Map them and you have a complete +trace. + +**1. Bracket the run** — wherever your framework starts and finishes a unit of +work: + +```python +with failproofai_sdk.session(): + with failproofai_sdk.agent(agent_name, goal=task): + result = framework.run(task) +``` + +**2. Bracket each tool** — in whatever the framework calls a tool wrapper or +middleware: + +```python +with failproofai_sdk.tool_call(name, input=args) as call: + call.output = original(**args) +``` + +**3. Pair each model call** — around the provider call: + +```python +failproofai_sdk.event.model_request(model=model, messages=messages) +reply = provider.complete(...) +failproofai_sdk.event.model_response( + model=model, response=text, + input_tokens=usage.prompt_tokens, + output_tokens=usage.completion_tokens, +) +``` + +If the framework has a node, step or middleware boundary worth seeing, add +`hook_triggered` / `hook_completed` around it. Do **not** promote it to a nested +`agent()`. + +### If the framework has a global callback surface + +If it does expose a process-wide registration point, four rules from the shipped +adapters are worth copying: + +- **Never raise into the caller.** Wrap every callback so a bug in yours cannot + affect the instrumented call. +- **Do not hold a contextvar token between two callbacks.** When start and end + are separate calls, `ContextVar.reset(token)` raises across asyncio tasks and + threads alike. Keep a map from the framework's own run id to identity, and pass + `session_id=` / `agent_id=` explicitly. +- **Bound every map.** Orphaned starts are normal — a crashed run, an unconsumed + stream, a skipped end callback. Unbounded, that is a leak in a long-lived + server. +- **Restore exactly what you replaced.** Save the original attribute object; if + it is no longer yours at teardown, leave it alone rather than deleting somebody + else's patch. + +--- + +## Why there is no AutoGen adapter + +Two reasons, and both are decisions rather than backlog items. + +**`autogen-core` is discontinued.** The `autogen-core` / `autogen-agentchat` +line has been unmaintained since 2025-09-30. An adapter against a dead callback +surface is a dependency that cannot be fixed when it drifts. + +**AG2 has no global auto-instrument hook.** AG2 — the community fork — exposes +no process-wide registration point equivalent to LangChain's configure hook, +CrewAI's event bus, LlamaIndex's dispatcher or Pydantic AI's capability list. +Instrumenting it means wrapping each agent at each construction site, which is a +wrapper you paste into your own code — precisely the ergonomics the adapters +exist to remove. + +If AutoGen matters to you, the three seams above record the same fifteen event +types at the same fidelity. It just costs you the call sites. + +--- + +## Pitfalls + +### Spans that never finish + +**Symptom:** the dashboard shows a run still going, hours later. + +**Cause:** an opening event with no closing one — a `model_request` with no +`model_response`, or a `tool_use` with no `tool_result`. + +**Fix:** use the scopes, which guarantee the pair even when the body raises. If +you call `event.*` by hand, use `try` / `finally`. + +--- + +### `ValueError: duration_ms is auto-computed` + +**Symptom:** passing `duration_ms` to a closing event raises. + +**Cause:** it is measured by the SDK from the matching opening event, so passing +it is rejected on `tool_result`, `hook_completed`, `agent_resume` and +`human_input`. + +**Fix:** do not pass it. The exception is `model_response`, where it **is** +accepted, because only you know the real provider latency — and it must be an +`int`, since a float silently nulls the column server-side. + +--- + +### `TypeError: ... missing session_id` + +**Symptom:** a `TypeError` naming `session_id` from inside a worker. + +**Cause:** you emitted from a thread that never inherited the context. + +**Fix:** `failproofai_sdk.propagate()`. See [Threads and async](#threads-and-async). + +This is deliberately a loud error rather than a silent drop: an event with no +session is skipped by ingest and answered `200`, which is the exact silent +failure the identity layer exists to prevent. + +--- + +### Extra fields silently disappearing + +**Symptom:** you passed `model="x"` as an extra field and it did not show up, or +it overwrote something. + +**Cause:** extras are merged **last**, so an extra named like a declared field — +`tool_name`, `model`, `outcome`, `input_tokens` — would overwrite the real field +and change a promoted column. + +**Fix:** namespace yours. The adapters use an `fw_` prefix: + +```python +failproofai_sdk.event.tool_use( + tool_name="search", tool_call_id="c1", + my_region="eu-west-1", # fine + # model="x", # dropped, with a warning +) +``` + +--- + +### `agent_id` full of UUIDs + +**Symptom:** the agent filter has thousands of entries. + +**Cause:** `agent_id` is a low-cardinality facet column, and you put a run id in +it. + +**Fix:** use a role or node name. Put the real id in a payload field. + +--- + +## Runnable examples + +| file | what it shows | events | +|---|---|---| +| [`examples/quickstart.py`](examples/quickstart.py) | the three scopes, ~40 lines | 6 | +| [`examples/research_agent.py`](examples/research_agent.py) | a real OpenAI tool-calling loop, hand-instrumented | 14 | + +```bash +pip install failproofai-sdk openai +export OPENAI_API_KEY=sk-... +export FPAI_MODEL=gpt-4o-mini +python docs/manual/examples/quickstart.py +``` + +Both were run against a live model before shipping. diff --git a/sdk/python/docs/manual/examples/quickstart.py b/sdk/python/docs/manual/examples/quickstart.py new file mode 100644 index 000000000..7ed1ee624 --- /dev/null +++ b/sdk/python/docs/manual/examples/quickstart.py @@ -0,0 +1,57 @@ +"""no framework — the scopes and `event.*` directly. + + pip install failproofai-sdk + python docs/manual/examples/quickstart.py + +There is no adapter here and nothing to instrument. This is the path for an +agent you wrote yourself, or a framework failproof does not support yet. + +Three scopes do the identity work: + + session() binds a session id. emits nothing. + agent() brackets a span with agent_start / agent_end. + tool_call() brackets a tool with tool_use / tool_result. + +Everything inside them can omit `session_id=` and `agent_id=` — the scopes bind +them on contextvars and every `event.*` call reads them back. +""" +import sys +from pathlib import Path + +# Only so this file can import the shared trace printer from docs/_shared/. +# Delete these two lines and the `_shared` import below and the example still +# instruments correctly — it just stops printing its own trace at the end. +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +import failproofai_sdk +from _shared import banner, model, trace + +failproofai_sdk.configure(environment="examples") + + +def look_up(city: str) -> str: + return {"tokyo": "37M", "delhi": "33M"}.get(city.lower(), "unknown") + + +def main() -> None: + banner("manual quickstart", "no framework · scopes + event.*") + + with failproofai_sdk.session() as sid: + with failproofai_sdk.agent("main", goal="answer one question"): + # tool_call() emits tool_use on entry and tool_result on exit, + # measuring duration and recording an exception as an error. + with failproofai_sdk.tool_call("population", input={"city": "tokyo"}) as call: + call.output = look_up("tokyo") + + failproofai_sdk.event.model_request(model=model(), messages=[ + {"role": "user", "content": "population of tokyo?"} + ]) + failproofai_sdk.event.model_response( + model=model(), output_tokens=3, duration_ms=210, content="37M" + ) + + trace(sid, title="manual quickstart") + + +if __name__ == "__main__": + main() diff --git a/sdk/python/docs/manual/examples/research_agent.py b/sdk/python/docs/manual/examples/research_agent.py new file mode 100644 index 000000000..6f11330de --- /dev/null +++ b/sdk/python/docs/manual/examples/research_agent.py @@ -0,0 +1,133 @@ +"""no framework — a real agent loop, hand-instrumented. + + pip install failproofai-sdk openai + python docs/manual/examples/research_agent.py + +A working tool-calling loop against the openai api with no agent framework at +all, instrumented by hand. This is the reference for "my agent is bespoke". + +Every event an adapter would emit for you appears here explicitly, so you can +see exactly what the adapters are doing on your behalf: + + session() -> identity only, no event + agent() -> agent_start / agent_end + event.model_request/response -> one pair per llm turn, with usage + tool_call() -> tool_use / tool_result, duration measured + event.error -> anything you want on the errors surface + +The one rule: emit the pairs. `model_request` without `model_response` is an +open span the dashboard renders as still-running forever. +""" +import json +import sys +from pathlib import Path + +# Only so this file can import the shared trace printer from docs/_shared/. +# Delete these two lines and the `_shared` import below and the example still +# instruments correctly — it just stops printing its own trace at the end. +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +import failproofai_sdk +from _shared import banner, model, trace +from openai import OpenAI + +failproofai_sdk.configure(environment="examples") + +client = OpenAI() + +_PRICE = {"widget": 42.0, "gadget": 17.5} +_STOCK = {"widget": 120, "gadget": 0} + +TOOLS = [ + { + "type": "function", + "function": { + "name": "price_of", + "description": "Unit price of an item. Valid: widget, gadget.", + "parameters": { + "type": "object", + "properties": {"item": {"type": "string"}}, + "required": ["item"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "stock_of", + "description": "Units in stock. Valid: widget, gadget.", + "parameters": { + "type": "object", + "properties": {"item": {"type": "string"}}, + "required": ["item"], + }, + }, + }, +] + + +def run_tool(name: str, args: dict) -> str: + if name == "price_of": + return str(_PRICE[args["item"].lower().strip()]) + if name == "stock_of": + return str(_STOCK[args["item"].lower().strip()]) + raise LookupError(f"unknown tool {name!r}") + + +def turn(messages: list) -> object: + """One llm call, bracketed by the model_request/model_response pair.""" + failproofai_sdk.event.model_request( + model=model(), + messages=[{"role": m.get("role"), "content": str(m.get("content"))[:200]} + for m in messages], + ) + reply = client.chat.completions.create( + model=model(), messages=messages, tools=TOOLS + ) + usage = reply.usage + failproofai_sdk.event.model_response( + model=model(), + content=reply.choices[0].message.content or "", + input_tokens=getattr(usage, "prompt_tokens", None), + output_tokens=getattr(usage, "completion_tokens", None), + ) + return reply.choices[0].message + + +def main() -> None: + banner("manual research agent", "no framework · hand-instrumented loop") + + messages = [ + {"role": "system", "content": "Use the tools for every number. Be terse."}, + {"role": "user", "content": "Price and stock for widget and gadget?"}, + ] + + with failproofai_sdk.session() as sid: + with failproofai_sdk.agent("inventory", goal="price and stock report"): + for _ in range(4): # bounded: an unbounded agent loop is its own bug + message = turn(messages) + calls = message.tool_calls or [] + if not calls: + print(" answer:", (message.content or "").strip()[:200], "\n") + break + + messages.append(message.model_dump(exclude_none=True)) + for call in calls: + args = json.loads(call.function.arguments or "{}") + # tool_call() emits tool_use now and tool_result on exit, + # turning a raised exception into `error` on the result. + with failproofai_sdk.tool_call( + call.function.name, tool_call_id=call.id, input=args + ) as handle: + handle.output = run_tool(call.function.name, args) + messages.append({ + "role": "tool", + "tool_call_id": call.id, + "content": str(handle.output), + }) + + trace(sid, title="manual research agent") + + +if __name__ == "__main__": + main() diff --git a/sdk/python/docs/pydantic_ai/README.md b/sdk/python/docs/pydantic_ai/README.md new file mode 100644 index 000000000..b9d9d162e --- /dev/null +++ b/sdk/python/docs/pydantic_ai/README.md @@ -0,0 +1,323 @@ +# Pydantic AI + +- [Install](#install) +- [The integration](#the-integration) +- [Read this first: construction order](#read-this-first-construction-order) +- [How it attaches](#how-it-attaches) +- [What gets recorded](#what-gets-recorded) +- [A complete example](#a-complete-example) +- [Options](#options) +- [Errors, retries and control flow](#errors-retries-and-control-flow) +- [Pitfalls](#pitfalls) +- [Runnable examples](#runnable-examples) + +--- + +## Install + +```bash +pip install 'failproofai-sdk[pydantic-ai]' # pins pydantic-ai-slim >=2.0,<3 +``` + +**Supported:** `pydantic-ai-slim` 2.0 → 3.0. 2.0 is a capability floor: it is the +release that removed `Agent(instrument=...)` and introduced `AbstractCapability`, +which is the entire surface this adapter is built on. There is no way to +instrument 1.x with this adapter. + +--- + +## The integration + +```python +import failproofai_sdk +from pydantic_ai import Agent + +failproofai_sdk.configure(environment="production") +failproofai_sdk.instrument() # <- BEFORE constructing any Agent + +agent = Agent("openai:gpt-4o-mini", system_prompt="Be terse.") + +with failproofai_sdk.session(): + result = agent.run_sync("...") +``` + +--- + +## Read this first: construction order + +**`instrument()` must run before you construct an `Agent`.** + +The capability is appended at construction time. An `Agent` built before +`instrument()` ran carries no capability and records **nothing** — and there is +no error, because nothing went wrong. This is the single most common way to get +an empty trace with this adapter. + +This bites hardest at module scope: + +```python +# agents.py +from pydantic_ai import Agent +agent = Agent("openai:gpt-4o-mini") # constructed at import time + + +# main.py +import agents # <- agent built HERE, uninstrumented +import failproofai_sdk +failproofai_sdk.instrument() # too late for agents.agent +``` + +Fix it by instrumenting first: + +```python +# main.py +import failproofai_sdk +failproofai_sdk.instrument() + +import agents # now the agent gets the capability +``` + +Or construct agents inside a function rather than at import time. + +Agents built **while** instrumented keep the capability object, so +`uninstrument()` flips a flag the capability reads rather than trying to reach +back into agents it no longer owns. That means you can safely uninstrument and +re-instrument without rebuilding your agents. + +--- + +## How it attaches + +`instrument()` wraps `Agent.__init__` so that every agent constructed afterwards +gets a `FailproofAI` capability appended to its capability list. + +The capability implements the middleware protocol Pydantic AI 2.0 introduced, so +it sees: + +- run start and end, +- each model request and response, with usage, +- each tool call and its result. + +It composes with your own capabilities — it is appended, not substituted, and it +declares an ordering so it wraps the outside of the stack rather than +interfering with middleware you added. + +--- + +## What gets recorded + +| Pydantic AI | Failproof event | When | +|---|---|---| +| agent run | `agent_start` / `agent_end` | `run` / `run_sync` / `run_stream` | +| model request | `model_request` / `model_response` | each model call, with `usage` | +| tool call | `tool_use` / `tool_result` | each tool call, with the args the model sent | +| `ModelRetry` from a tool | `tool_result` with `error` | the tool asked for a retry | +| unhandled exception | `error` + `agent_end(outcome="failed")` | the run raises | + +There is **no** `hook_triggered` / `hook_completed` pair here, and no +human-in-the-loop pair. That is not a gap — Pydantic AI has no node or step +boundary to bracket, and no built-in human pause. If you build either, emit the +events yourself; see [`../manual/`](../manual/). + +`output_type` makes no difference to the trace. A typed run and a string run +produce the same events. + +--- + +## A complete example + +```python +import failproofai_sdk +from pydantic import BaseModel +from pydantic_ai import Agent, ModelRetry + +failproofai_sdk.configure(environment="production") +failproofai_sdk.instrument() + +PRICE = {"widget": 42.0, "gadget": 17.5} +STOCK = {"widget": 120, "gadget": 0} + + +class Report(BaseModel): + """What the run must produce. Validated by pydantic_ai, not by us.""" + + headline: str + out_of_stock: list[str] + + +agent = Agent( + "openai:gpt-4o-mini", + output_type=Report, + system_prompt="Use the tools for every number. If a tool fails, note it and continue.", +) + + +@agent.tool_plain +def price_of(item: str) -> float: + """Unit price of an item. Valid: widget, gadget.""" + return PRICE[item.lower().strip()] + + +@agent.tool_plain +def stock_of(item: str) -> int: + """Units in stock. Valid: widget, gadget.""" + return STOCK[item.lower().strip()] + + +@agent.tool_plain +def restock_eta(item: str) -> str: + """Restock ETA. Not available for anything.""" + # ModelRetry hands the failure back to the model instead of raising out of + # run_sync. Recorded as an errored tool_result either way. + raise ModelRetry(f"no restock schedule for {item!r} — answer without it") + + +with failproofai_sdk.session(): + with failproofai_sdk.agent("inventory", goal="stock report"): + result = agent.run_sync( + "For 'widget' and 'gadget', get price and stock. " + "For anything out of stock, try the restock ETA. Then produce the report." + ) + +print(result.output.headline) +print(result.output.out_of_stock) +``` + +Produces **20 events across 6 types**: + +``` + 1 +0.000s agent_start inventory + 2 +0.005s agent_start agent · under inventory + 3 +0.006s model_request gpt-5.6-terra + 4 +4.036s model_response gpt-5.6-terra · 90 out-tok + 5 +4.039s tool_use price_of + ... + 15 +7.028s tool_use restock_eta + 16 +7.029s tool_result restock_eta · error + 17 +7.032s model_request gpt-5.6-terra + 18 +10.411s model_response gpt-5.6-terra · 101 out-tok + 19 +10.416s agent_end agent · success + 20 +10.417s agent_end inventory · success +``` + +Note events 16–18: the tool failed, the model was told, it made another call and +recovered. The run ends `success` and the failure is still on the record. + +The nested `agent` span at depth 2 is Pydantic AI's own run, sitting inside the +`inventory` scope you opened. That is where the model and tool events hang. + +--- + +## Options + +```python +failproofai_sdk.instrument( + "pydantic_ai", + session_id=None, # pin every run to one session id + capture_content=True, # False drops prompts and completions from payloads +) +``` + +--- + +## Errors, retries and control flow + +Pydantic AI raises exceptions for three genuinely different things, and the +adapter distinguishes them: + +| exception | treated as | result | +|---|---|---| +| `ModelRetry`, `ToolRetryError`, `ToolFailedError` | **a real tool failure** | `tool_result` with `error`; run can still end `success` | +| `SkipToolExecution`, `SkipToolValidation`, `SkipModelRequest`, `CallDeferred`, `ApprovalRequired` | **control flow** | not an error; the run is being steered | +| anything else | **a failure** | `error` + `agent_end(outcome="failed")` | + +`ModelRetry` is deliberately in the first group, not the second. It means an +attempt genuinely failed and the model was asked to try again — which is exactly +what a tool span's `error` field is for. Classifying it as control flow would +hide real tool failures behind a green run. + +The control-flow list is resolved by name at import time and tolerates every one +of them being absent: a minor release renaming one degrades to "treat it as an +error" rather than raising an `AttributeError` inside your run. + +--- + +## Pitfalls + +### An empty trace, no errors anywhere + +**Symptom:** the run works, no warnings, and no events. + +**Cause:** the `Agent` was constructed before `instrument()` ran. See +[construction order](#read-this-first-construction-order). + +**Check it:** + +```python +agent = Agent("openai:gpt-4o-mini") +print([type(c).__name__ for c in agent.root_capability.capabilities]) +# ['FailproofAI', 'ToolSearch', 'PendingMessageDrainCapability'] +``` + +Pydantic AI merges the list you pass into one `root_capability`, so there is no +`agent.capabilities` attribute — reading that raises `AttributeError`. + +--- + +### A plain exception in a tool kills the run + +**Symptom:** `run_sync` raises instead of the model working around a failed tool. + +**Cause:** a bare `raise` propagates. That is Pydantic AI's design. + +**Fix:** raise `ModelRetry` with a message the model can act on. + +```python +@agent.tool_plain +def restock_eta(item: str) -> str: + """Restock ETA.""" + raise ModelRetry(f"no schedule for {item!r} — answer without it") +``` + +The failure is recorded as an errored `tool_result` either way; this only +decides whether the run survives it. + +--- + +### There is a nested agent span I did not create + +**Symptom:** wrapping in `failproofai_sdk.agent("inventory")` gives you +`inventory` **and** a child called `agent`. + +**Cause:** that child is Pydantic AI's own run span, and it is where the model +and tool events hang. It is correct. + +**Fix:** if you want one span instead of two, drop your own scope and let the +framework's span be the root — you lose the custom name. + +--- + +### Tracebacks look truncated at the top + +**Symptom:** the `traceback` field starts with `[older frames truncated]…`. + +**Cause:** Pydantic AI's async graph stack is comfortably longer than the 8KB +field limit. A traceback's last line is the exception itself, so this one field +is trimmed from the **front**, not the back — keeping the head like every other +field would ship 8KB of framework frames and drop the one line anybody reads. + +--- + +## Runnable examples + +| file | what it shows | events | +|---|---|---| +| [`examples/quickstart.py`](examples/quickstart.py) | one tool, one turn | 8 | +| [`examples/research_agent.py`](examples/research_agent.py) | 3 tools, typed output, one tool that retries | 20 | + +```bash +export OPENAI_API_KEY=sk-... +export FPAI_MODEL=gpt-4o-mini +python docs/pydantic_ai/examples/quickstart.py +``` + +Both were run against a live model before shipping. diff --git a/sdk/python/docs/pydantic_ai/examples/quickstart.py b/sdk/python/docs/pydantic_ai/examples/quickstart.py new file mode 100644 index 000000000..f94a5b31c --- /dev/null +++ b/sdk/python/docs/pydantic_ai/examples/quickstart.py @@ -0,0 +1,48 @@ +"""pydantic_ai — the smallest thing that produces a trace. + + pip install 'failproofai-sdk[pydantic-ai]' + python docs/pydantic_ai/examples/quickstart.py + +`instrument()` appends a capability to every `Agent` constructed afterwards, so +runs, tools and model calls are captured without touching the call sites. + +Order matters here and nowhere else: an `Agent` built BEFORE `instrument()` does +not carry the capability. Construct agents after instrumenting, or at import +time in a module imported after it. +""" +import sys +from pathlib import Path + +# Only so this file can import the shared trace printer from docs/_shared/. +# Delete these two lines and the `_shared` import below and the example still +# instruments correctly — it just stops printing its own trace at the end. +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +import failproofai_sdk +from _shared import banner, model, trace +from pydantic_ai import Agent + +failproofai_sdk.configure(environment="examples") +failproofai_sdk.instrument() + +agent = Agent(f"openai:{model()}", system_prompt="Be terse.") + + +@agent.tool_plain +def population(city: str) -> str: + """Population of a city.""" + return {"tokyo": "37M", "delhi": "33M"}.get(city.lower(), "unknown") + + +def main() -> None: + banner("pydantic_ai quickstart", "one tool, one turn") + + with failproofai_sdk.session() as sid: + result = agent.run_sync("Population of Tokyo? Use the tool.") + + print(" answer:", str(result.output).strip()[:120]) + trace(sid, title="pydantic_ai quickstart") + + +if __name__ == "__main__": + main() diff --git a/sdk/python/docs/pydantic_ai/examples/research_agent.py b/sdk/python/docs/pydantic_ai/examples/research_agent.py new file mode 100644 index 000000000..b1e14a8fe --- /dev/null +++ b/sdk/python/docs/pydantic_ai/examples/research_agent.py @@ -0,0 +1,94 @@ +"""pydantic_ai — a typed multi-tool run, plus a tool that raises. + + pip install 'failproofai-sdk[pydantic-ai]' + python docs/pydantic_ai/examples/research_agent.py + +What this demonstrates that the quickstart does not: + +* three tools over several turns, each a `tool_use`/`tool_result` pair carrying + the arguments the model actually sent; +* a tool that raises `ModelRetry`, recorded as `tool_result` with `error` set + while the run still finishes `success` — the adapter deliberately does NOT + treat a retry as control flow, because an attempt really did fail and that is + what a tool span's `error` field is for; +* a typed `output_type`, so the run ends with a validated object and the trace + still reads the same — instrumentation does not care about your result type; +* `usage` on `model_response`, which is where per-run token spend comes from. +""" +import sys +from pathlib import Path + +# Only so this file can import the shared trace printer from docs/_shared/. +# Delete these two lines and the `_shared` import below and the example still +# instruments correctly — it just stops printing its own trace at the end. +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +import failproofai_sdk +from _shared import banner, model, trace +from pydantic import BaseModel +from pydantic_ai import Agent, ModelRetry + +failproofai_sdk.configure(environment="examples") +failproofai_sdk.instrument() + +_PRICE = {"widget": 42.0, "gadget": 17.5} +_STOCK = {"widget": 120, "gadget": 0} + + +class Report(BaseModel): + """What the run must produce. Validated by pydantic_ai, not by us.""" + + headline: str + out_of_stock: list[str] + + +agent = Agent( + f"openai:{model()}", + output_type=Report, + system_prompt=( + "Use the tools for every number. If a tool fails, note it and continue." + ), +) + + +@agent.tool_plain +def price_of(item: str) -> float: + """Unit price of an item. Valid: widget, gadget.""" + return _PRICE[item.lower().strip()] + + +@agent.tool_plain +def stock_of(item: str) -> int: + """Units in stock. Valid: widget, gadget.""" + return _STOCK[item.lower().strip()] + + +@agent.tool_plain +def restock_eta(item: str) -> str: + """Restock ETA. Not available for anything.""" + # `ModelRetry` is how pydantic_ai hands a failure back to the model instead + # of raising out of `run_sync`. The failproof trace records it as an errored + # tool_result either way; this just lets the run finish so you can watch the + # model work around it. + raise ModelRetry(f"no restock schedule for {item!r} — answer without it") + + +def main() -> None: + banner("pydantic_ai research agent", "3 tools · typed output · one failing call") + + prompt = ( + "For 'widget' and 'gadget', get price and stock. For anything out of " + "stock, try the restock ETA. Then produce the report." + ) + + with failproofai_sdk.session() as sid: + with failproofai_sdk.agent("inventory", goal="stock report"): + result = agent.run_sync(prompt) + + print(" headline: ", result.output.headline[:120]) + print(" out of stock: ", result.output.out_of_stock, "\n") + trace(sid, title="pydantic_ai research agent") + + +if __name__ == "__main__": + main() diff --git a/sdk/python/failproofai_sdk/__init__.py b/sdk/python/failproofai_sdk/__init__.py new file mode 100644 index 000000000..6e6bc3b30 --- /dev/null +++ b/sdk/python/failproofai_sdk/__init__.py @@ -0,0 +1,114 @@ +"""Telemetry for AI agents: emit events, spool them, let the daemon ship them. + +Three surfaces, in the order most people meet them: + +* **Scopes** — `session()`, `agent()`, `tool_call()`. Context managers that bind + run identity and, for the latter two, bracket a run with its own events. Work + under `with` and `async with`. +* **Adapters** — `instrument()`. Auto-detects LangChain/LangGraph, CrewAI, + LlamaIndex and Pydantic AI in the process and wires them to the scopes above. +* **`event.*`** — the 15 event methods, for anything the adapters do not cover. + +`session_id` and `agent_id` are optional on every event method: omitted, they +resolve from the enclosing scope. Nothing bound and nothing passed is an error, +never a silent drop — ingest skips an event with no session and answers 200. +""" + +from typing import Any + +from failproofai_sdk._version import __version__ +from failproofai_sdk._environment import set_environment +from failproofai_sdk._resolver import set_base_dir +from failproofai_sdk._context import Identity, current, propagate +from failproofai_sdk._runtime import event +from failproofai_sdk._scopes import agent, session, tool_call +from failproofai_sdk._writer import _validated_interval +from failproofai_sdk import _runtime + +__all__ = [ + "__version__", + "configure", + "event", + "session", + "agent", + "tool_call", + "current", + "Identity", + "propagate", + "instrument", + "uninstrument", + "_writer", +] + + +def configure( + *, + base_dir=None, + flush_interval: float = 0.5, + environment: str | None = None, +) -> None: + """Configure the SDK. Call once at startup before any event.* calls. + + Args: + base_dir: Override the spool root. Pass None to resolve it: + $AGENTEYE_HOME if set, else ~/.failproofai/custom-agents + (honouring $FAILPROOFAI_HOME). + + The default moved here from ~/.agenteye. `failproofaid` + watches both roots, so on a host running it this only + changes which directory the files appear in, and batches + already spooled under the old root are still collected. + A host running the older `agenteye-collector` — which reads + $AGENTEYE_HOME or ~/.agenteye and nothing else — sets + AGENTEYE_HOME=~/.agenteye. + flush_interval: Seconds between flush cycles. Default 0.5 (500ms). + environment: Deployment environment label (e.g. "production", "staging"). + Can also be set via the AGENTEYE_ENVIRONMENT env var. + Defaults to "dev" when neither is set. + + Raises: + ValueError: if `flush_interval` is not a finite number greater than zero. + Checked here, before anything is applied, so a rejected call leaves + the SDK exactly as it was rather than with a new base_dir and the old + interval. + """ + flush_interval = _validated_interval(flush_interval) + set_base_dir(base_dir) + _runtime.writer.set_flush_interval(flush_interval) + set_environment(environment) + + +def instrument(framework: str | None = None, **options: Any): + """Install the framework adapters. + + With no argument, auto-detects the frameworks already imported in this + process. Pass a name (`"langchain"`, `"crewai"`, `"llama_index"`, + `"pydantic_ai"`) to install exactly one. + + The import is inside the function on purpose: `failproofai_sdk.integrations` + reaches for framework packages, and `import failproofai_sdk` is + contractually zero-dependency — a promise `tests/test_zero_dependencies.py` + enforces both by scanning the core modules and by launching a fresh + interpreter to prove no framework lands in `sys.modules`. + """ + from failproofai_sdk.integrations import instrument as _impl + + return _impl(framework, **options) + + +def uninstrument(framework: str | None = None): + """Reverse `instrument()`, restoring the original attributes. + + Lazy-imported for the same reason as `instrument()`. + """ + from failproofai_sdk.integrations import uninstrument as _impl + + return _impl(framework) + + +# MUST be last: any `import failproofai_sdk.<sub>` binds the *module* onto this +# package as `failproofai_sdk._writer`. Rebinding it here to the instance is what +# keeps the published `failproofai_sdk._writer.flush_now()` recipe working — and +# is why a test reaching for the MODULE has to go through +# `sys.modules["failproofai_sdk._writer"]`. +_writer = _runtime.writer diff --git a/sdk/python/failproofai_sdk/_context.py b/sdk/python/failproofai_sdk/_context.py new file mode 100644 index 000000000..de70135dc --- /dev/null +++ b/sdk/python/failproofai_sdk/_context.py @@ -0,0 +1,166 @@ +"""Ambient run identity, carried on contextvars. + +Before this module the SDK had no ambient session: every `event.*` call took +`session_id` and `agent_id` as required keyword arguments and nothing propagated +them. Threading both through every function that might emit an event is what makes +instrumentation sprawl into a diff nobody wants to review. + +Two contextvars carry it instead. Read them through `current()`, or let +`failproofai_sdk._events` fall back to them when a caller omits the identity. + +Why a tuple and not a list +-------------------------- +`_AGENT_STACK` holds a **tuple**. A `ContextVar[list]` is shared *by reference* +across tasks and threads, so `.append()` in one task mutates the value every other +task sees — which is exactly the cross-run event mixing contextvars are here to +prevent, wearing a contextvars costume. It passes every single-threaded test. +Push is `set(stack + (aid,))`; pop is `reset(token)`. + +There is deliberately no `_AGENT_ID` var: the top of the stack *is* the current +agent id, so the two cannot drift apart, and `parent_id` is `stack[-2]`. +""" + +import contextvars +import functools +import logging +from dataclasses import dataclass +from typing import Any, Callable + +logger = logging.getLogger(__name__) + +# The agent_id used when events are emitted with a session bound but no agent +# scope. "main" is the convention the skill and the reference integration already +# teach, so an un-scoped event lands somewhere sensible rather than raising. +DEFAULT_AGENT_ID = "main" + +_SESSION_ID: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "failproofai_sdk_session_id", default=None +) +_AGENT_STACK: contextvars.ContextVar[tuple[str, ...]] = contextvars.ContextVar( + "failproofai_sdk_agent_stack", default=() +) + + +@dataclass(frozen=True, slots=True) +class Identity: + """The run identity in scope. Never None — check `session_id is None` instead.""" + + session_id: str | None + agent_id: str | None + parent_id: str | None + depth: int + + +def current() -> Identity: + """The identity bound to the current context. + + `failproofai_sdk.current().session_id is None` means nothing is bound — either no + scope was entered, or this is a fresh thread that did not inherit one (see + `propagate`). + """ + stack = _AGENT_STACK.get() + return Identity( + session_id=_SESSION_ID.get(), + agent_id=stack[-1] if stack else None, + parent_id=stack[-2] if len(stack) >= 2 else None, + depth=len(stack), + ) + + +def session_id() -> str | None: + """The bound session id, or None. Hot path — allocates no Identity.""" + return _SESSION_ID.get() + + +def agent_id() -> str: + """The current agent id, falling back to DEFAULT_AGENT_ID.""" + stack = _AGENT_STACK.get() + return stack[-1] if stack else DEFAULT_AGENT_ID + + +def parent_agent_id() -> str | None: + """The enclosing agent id, or None at depth 0 or 1.""" + stack = _AGENT_STACK.get() + return stack[-2] if len(stack) >= 2 else None + + +def bind_session(sid: str) -> contextvars.Token: + return _SESSION_ID.set(sid) + + +def push_agent(aid: str) -> contextvars.Token: + return _AGENT_STACK.set(_AGENT_STACK.get() + (aid,)) + + +def reset(token: contextvars.Token | None) -> None: + """Restore a contextvar to its pre-`set` value, tolerating a cross-context token. + + `ContextVar.reset()` raises `ValueError: Token was created in a different + Context` when the token was minted in another thread *or another asyncio task*. + That happens when a scope is entered in one task and exited in another, which is + the caller's bug — but an observability library's correct response is a debug + line, not an exception raised on top of whatever they were already doing. + """ + if token is None: + return + try: + token.var.reset(token) + except ValueError: + logger.debug( + "failproofai_sdk: context token reset across contexts; identity left as-is", + exc_info=True, + ) + + +Snapshot = tuple[str | None, tuple[str, ...]] + + +def snapshot() -> Snapshot: + """Capture the identity *values* currently bound.""" + return (_SESSION_ID.get(), _AGENT_STACK.get()) + + +def restore(snap: Snapshot) -> tuple[contextvars.Token, contextvars.Token]: + """Bind a snapshot into the calling context.""" + sid, stack = snap + return (_SESSION_ID.set(sid), _AGENT_STACK.set(stack)) + + +def discard(tokens: tuple[contextvars.Token, contextvars.Token] | None) -> None: + if tokens is None: + return + for token in reversed(tokens): + reset(token) + + +def propagate(fn: Callable[..., Any]) -> Callable[..., Any]: + """Wrap `fn` so it runs with the identity bound *right now*. + + pool.submit(failproofai_sdk.propagate(work), x) + pool.map(failproofai_sdk.propagate(work), items) + threading.Thread(target=failproofai_sdk.propagate(work)).start() + loop.run_in_executor(None, failproofai_sdk.propagate(work), x) + + contextvars propagate into asyncio tasks automatically but **not into new + threads** — a thread starts with an empty context, so without this every event + a worker emits is dropped (or, since this change, raises TypeError). + + This deliberately snapshots *values* rather than doing + `functools.partial(contextvars.copy_context().run, fn)`. A `Context` object + cannot be entered by two threads at once (`RuntimeError: cannot enter context: + ... is already entered`), so the copy_context form crashes the caller's worker + on any reuse — `pool.map`, a retried submit. Mutations made inside `ctx.run` + also persist in that Context, so a reused one leaks the previous call's agent + stack into the next. + """ + snap = snapshot() + + @functools.wraps(fn) + def _failproofai_propagated(*args: Any, **kwargs: Any) -> Any: + tokens = restore(snap) + try: + return fn(*args, **kwargs) + finally: + discard(tokens) + + return _failproofai_propagated diff --git a/sdk/python/failproofai_sdk/_environment.py b/sdk/python/failproofai_sdk/_environment.py new file mode 100644 index 000000000..ce01148e8 --- /dev/null +++ b/sdk/python/failproofai_sdk/_environment.py @@ -0,0 +1,63 @@ +import logging + +_DEFAULT_ENVIRONMENT = "dev" +_environment: str | None = None + +logger = logging.getLogger("failproofai_sdk") + + +def _reject_comma(env: str, source: str) -> None: + """A comma in `environment` makes ingest skip EVERY event carrying it. + + The endpoint splits this field on commas to build its filter facets, so a + line whose `environment` contains one is discarded — the whole line, not the + field. It answers 200 with `{"accepted":0,"skipped":N}`, the daemon deletes + the delivered batch, and the run that produced it is simply never in the + dashboard: no exception here, nothing in the agent's output, and an empty + session list that looks exactly like an agent nobody ran. + + `failproofaid` already refuses a comma in `collector.environment` for this + reason (`crates/fpai-collect/src/config.rs`). The SDK is the other writer of + the same field and did not, so `AGENTEYE_ENVIRONMENT="prod,eu"` — a wholly + reasonable thing to type — silently threw away everything the process + emitted. + """ + if "," in env: + raise ValueError( + f"environment must not contain a comma (got {env!r} from {source}). " + "The ingest endpoint skips every event whose environment has one, so " + "this would silently discard all telemetry from this process. Use a " + "single label, e.g. 'prod-eu'." + ) + + +def get_environment() -> str: + if _environment is not None: + return _environment + import os + + raw = os.environ.get("AGENTEYE_ENVIRONMENT") + if not raw: + return _DEFAULT_ENVIRONMENT + if "," in raw: + # Raising here would blow up inside `to_dict()` on an arbitrary event, + # far from the thing that set it, and take the caller's agent down with + # it — a telemetry library must not do that. Warn once and fall back to + # a label ingest will actually accept, so the events land under a + # visibly-wrong environment instead of vanishing. + logger.warning( + "failproofai_sdk: AGENTEYE_ENVIRONMENT=%r contains a comma, which makes " + "the ingest endpoint skip every event carrying it. Falling back to %r. " + "Use a single label, e.g. 'prod-eu'.", + raw, + _DEFAULT_ENVIRONMENT, + ) + return _DEFAULT_ENVIRONMENT + return raw + + +def set_environment(env: str | None) -> None: + global _environment + if env: + _reject_comma(env, "configure(environment=...)") + _environment = env if env else None diff --git a/sdk/python/failproofai_sdk/_events.py b/sdk/python/failproofai_sdk/_events.py new file mode 100644 index 000000000..8ce2229a2 --- /dev/null +++ b/sdk/python/failproofai_sdk/_events.py @@ -0,0 +1,709 @@ +import logging +from datetime import datetime, timezone +from typing import Any + +from failproofai_sdk import _context + +from failproofai_sdk._schema import ( + AgentEndEvent, + AgentPauseEvent, + AgentResumeEvent, + AgentStartEvent, + ErrorEvent, + HookCompletedEvent, + HookTriggeredEvent, + HumanInputEvent, + HumanInterruptEvent, + HumanPauseEvent, + HumanWaitEvent, + ModelRequestEvent, + ModelResponseEvent, + ToolResultEvent, + ToolUseEvent, +) + +logger = logging.getLogger(__name__) + +# session_id and agent_id are explicit signature params on every method, so Python raises +# TypeError before our validator runs if a caller tries to pass them as extra **fields. +# timestamp and type are not in the signature, so they land in **fields and are caught here. +_RESERVED = frozenset({"timestamp", "session_id", "agent_id", "type", "environment"}) + +# Payload keys ingest lifts out of the JSON blob into unsigned 32-bit columns via +# `pu32()`. Everything else is stored as-is and can be any shape, but these three +# are read with a typed accessor that returns None on a mismatch — and a None +# there is written as NULL under a 200 OK. Nothing is logged, nothing is +# rejected, and the row still arrives, so the only symptom is a column that is +# empty for some events and not others. +# +# `duration_ms` is refused outright on the four events that MEASURE it. These +# checks cover the other way in: any of the three passed as a custom field on an +# event that does not name it, plus `model_response`'s own two parameters, which +# are the ones a caller is most likely to fill straight from a provider response. +_PROMOTED_NUMERIC = frozenset({"duration_ms", "input_tokens", "output_tokens"}) + +_U32_MAX = 2**32 - 1 + + +def _validate_promoted_numeric(name: str, value) -> None: + """Reject anything `pu32()` would silently turn into NULL. + + Rejecting rather than coercing, and at the boundary rather than in the + writer, for the same reason `_validated_interval` does: this is the last + point where the caller still has a stack trace pointing at their own call. + A float is a mistake worth hearing about — the server drops it whole rather + than rounding it — and rounding it here would hide that from the one person + who could fix the source of it. + + `bool` is checked before `int` because it IS an int in Python, and `True` + would otherwise sail through and store as 1. + """ + if value is None: + return + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError( + f"{name} must be an int (the server reads it as an unsigned 32-bit " + f"integer and stores NULL for anything else), got {type(value).__name__}: {value!r}" + ) + if not 0 <= value <= _U32_MAX: + raise ValueError( + f"{name} must be between 0 and {_U32_MAX} (an unsigned 32-bit " + f"integer), got {value!r}" + ) + + +def _validate_identity(name: str, value) -> None: + """Reject an id the server will skip, at the point the caller can see it. + + `session_id` and `agent_id` are on every one of the 15 event types and are + what everything downstream groups by. Ingest requires each to be a JSON + string: hand it anything else and the row is SKIPPED — and the response is + `200 OK` with `{"accepted": 0, "skipped": 1}`, so nothing upstream learns. + The SDK reports success, the collector deletes the batch, and the event is + gone. Verified against the live server for int, null and object. + + `None` is the realistic way in — an uninitialised variable, a dict lookup + that missed, an id threaded through a code path that forgot to set it. The + caller does not get a wrong number here, they get no data at all, and the + only symptom is a dashboard that is emptier than it should be. + + Empty and whitespace-only are refused as well, and those the server DOES + accept. That is the worse outcome of the two: every event lands, grouped + under one blank id, so the data looks present and is silently merged. + """ + if not isinstance(value, str): + # TypeError, not ValueError: the wrong TYPE was supplied, and this is + # also what a caller got before identity became optional — omitting a + # required keyword argument has always been a TypeError, so code that + # catches one keeps working. + raise TypeError( + f"{name} must be a str — the server skips any event whose {name} is " + f"not a JSON string, and answers 200 as though it stored it. " + f"Got {type(value).__name__}: {value!r}" + ) + if not value.strip(): + raise ValueError( + f"{name} must not be empty — the server accepts it, so every event " + f"sent this way is silently grouped under one blank id." + ) + +def _resolve_identity(session_id, agent_id) -> "tuple[str, str]": + """Fill an omitted `session_id`/`agent_id` from the ambient scope, then validate. + + Both arguments were required on all 15 methods, which meant threading them + through every function that might emit — the diff nobody wants to review, and + the reason the reference integration shipped a contextvars wrapper as markdown + for customers to paste in. `session()` / `agent()` bind them instead. + + ORDER MATTERS. The validation runs on the RESOLVED value, not the argument. + Validating first would reject every ambient call; resolving without validating + would put the silent-skip back: ingest drops an event whose `session_id` is not + a JSON string and answers `200 OK` with `{"accepted":0,"skipped":1}`, so a run + with nothing bound would vanish rather than fail. + + Called AFTER `_validate_fields`, deliberately. A reserved `**field` is a + fault in the call itself and reads identically from anywhere, so reporting it + first gives a stable, reproducible message; the identity error depends on + where the call was made from, and is the less useful of the two to hear when + both are true. + + `agent_id` falls back to `DEFAULT_AGENT_ID` rather than raising — an event + emitted inside `session()` with no `agent()` around it lands somewhere sensible, + which is the convention the skill already teaches. `session_id` has no such + default: inventing one would scatter a run across as many sessions as it has + emit sites. + """ + if session_id is None: + session_id = _context.session_id() + if agent_id is None: + agent_id = _context.agent_id() # DEFAULT_AGENT_ID when no agent scope + + if session_id is None: + # TypeError for the same reason: this is a missing required argument, + # which is what it literally was until the scopes made it optional. + raise TypeError( + "session_id is required and nothing is bound. Pass session_id=..., or " + "wrap the call in `with failproofai_sdk.session():` / " + "`with failproofai_sdk.agent(\"name\"):`. A new thread does not inherit " + "the ambient scope — hand work to it with " + "`failproofai_sdk.propagate(fn)`." + ) + _validate_identity("session_id", session_id) + _validate_identity("agent_id", agent_id) + return session_id, agent_id + + +def _measured_duration_ms(start_ts, end_ts) -> "int | None": + """Whole milliseconds between a paired start and end, or None. + + The four paired events each computed this inline, and none of them applied + the range the SERVER enforces — the same range `_validate_promoted_numeric` + refuses a caller for. `pu32()` reads `duration_ms` as an unsigned 32-bit + integer and stores NULL for anything outside it, at `200 OK`, so an + out-of-range duration is not an error anywhere: the row lands with an empty + column and nothing says why. + + Two ways to leave the range, both reachable without anything being wrong + with the caller: + + * **over.** 2**32 ms is ~49.7 days. A `human_wait` answered after a long + weekend, or an `agent_pause` resumed a month later, is an ordinary + lifetime for these pairs, not an abuse of them. + * **under.** These are wall-clock readings from `datetime.now()`, so an NTP + step backwards between start and end yields a NEGATIVE interval. `round()` + keeps the sign, and a negative into an unsigned column is the same silent + NULL. + + Omitted rather than clamped. A clamped 49.7 days is indistinguishable from a + measurement, and the whole reason `duration_ms` is computed here instead of + accepted from the caller is that a reported duration is unfalsifiable. An + absent field is at least honest, and the timestamps are still on both events + for anyone who wants to do the subtraction themselves. + """ + if start_ts is None: + return None + ms = round((end_ts - start_ts).total_seconds() * 1000) + if not 0 <= ms <= _U32_MAX: + logger.warning( + "Failproof AI omitted duration_ms=%d: outside the unsigned 32-bit range " + "the server stores it in (0..%d). The event is unaffected.", + ms, + _U32_MAX, + ) + return None + return ms + + +# Hard cap on `_pending` correlation map size. Orphaned starts (a `tool_use` with +# no `tool_result`, a `human_wait` the user never answers, etc.) would otherwise +# grow this dict unbounded in a long-running process. At the cap we evict the +# oldest entry FIFO — Python dicts preserve insertion order since 3.7. +_PENDING_CAP = 10_000 + + +# Every pairing in `_pending` is keyed by what it pairs and by the SESSION it +# belongs to — and deliberately NOT by the agent. +# +# The rule: include what makes the id unique, exclude what can legitimately +# change between the start event and the end event. +# +# * KIND belongs in the key. Tool pairs keyed on the bare `tool_call_id` and hook +# pairs on the bare `hook_id` shared one flat keyspace, so a caller whose tool +# call and hook happened to share an id — not exotic, both are frequently the +# harness's own step id — got a `hook_completed` that consumed the `tool_use` +# timestamp, and then a `tool_result` with no duration at all. +# +# * SESSION belongs in the key. `_pending` lives on one process-wide namespace, +# so two sessions in one process — a supervisor running agents concurrently, +# the ordinary multi-agent shape — collided on any shared step id. Starting +# `step-1` in session A and then in session B overwrote A's timestamp; A's +# result reported B's interval and B's reported none. +# +# * AGENT DOES NOT. This was the tempting third component and it is wrong. Once a +# framework runs tools inside sub-agents, a `tool_use` opened under `planner` +# and closed under `worker` is routine — LangGraph and CrewAI both do it — and +# an agent-scoped key makes those pairs miss entirely, silently dropping +# `duration_ms` for exactly the nested runs that most need it. A session cannot +# change under a pair; an agent can. +# +# These are correlation keys only; they are never emitted and never leave the +# process, so the shape changes no wire format. It only changes `duration_ms` in +# the colliding cases, from a fabricated or missing value to a correct one. +def _tool_key(session_id: str, tool_call_id: str) -> str: + return f"tool:{session_id}:{tool_call_id}" + + +def _hook_key(session_id: str, hook_id: str) -> str: + return f"hook:{session_id}:{hook_id}" + + +class EventNamespace: + def __init__(self, writer) -> None: + self._writer = writer + self._pending: dict[str, datetime] = {} + + def _track_pending(self, key: str, ts: datetime) -> None: + # Evicting has to tolerate another thread doing the same thing, because + # this runs on the CALLER'S agent loop and a raise here is a crash in + # their code, not a lost measurement. + # + # `len()` then `next(iter())` then remove is a read-modify-write, and + # nothing serialises the three. Two threads arriving at the cap together + # pick the SAME victim, and the second `del` raised KeyError straight + # out of `event.tool_use()`. Reproduced at 24 crashes per 30_000 calls + # across 10 threads — and only once `_pending` is full, which is the + # long-running multi-agent process this cap exists for in the first + # place. `next(iter())` has two more shapes for the same reason: + # StopIteration if the dict was emptied, RuntimeError if it was resized + # between the iterator and the first step. + # + # No lock, deliberately. `_pending` operations are nanoseconds and a + # lock held at the instant of a `fork()` is inherited locked by a thread + # that does not exist in the child — the exact hazard `_writer` rebuilds + # its Event and lock to avoid. Tolerant operations have no such edge. + # + # The cost is that the cap is approximate under contention: several + # threads may insert after each evicting once. That is the same trade + # `EventWriter.submit` already makes — a backstop against unbounded + # growth, not an exact quota. + if len(self._pending) >= _PENDING_CAP: + try: + oldest = next(iter(self._pending)) + except (StopIteration, RuntimeError): # emptied or resized under us + pass + else: + self._pending.pop(oldest, None) # may already be gone + self._pending[key] = ts + + def _validate_fields(self, fields: dict) -> None: + bad = _RESERVED & fields.keys() + if bad: + raise ValueError(f"Reserved field names cannot be used as custom fields: {sorted(bad)}") + for name in _PROMOTED_NUMERIC & fields.keys(): + _validate_promoted_numeric(name, fields[name]) + + @staticmethod + def _now() -> datetime: + return datetime.now(timezone.utc) + + @staticmethod + def _fmt_ts(dt: datetime) -> str: + return dt.strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z" + + def tool_use( + self, + *, + session_id: str | None = None, + agent_id: str | None = None, + tool_name: str, + tool_call_id: str, + input: dict | None = None, + **fields, + ) -> None: + self._validate_fields(fields) + session_id, agent_id = _resolve_identity(session_id, agent_id) + ts = self._now() + self._track_pending(_tool_key(session_id, tool_call_id), ts) + self._writer.submit( + ToolUseEvent( + timestamp=self._fmt_ts(ts), + session_id=session_id, + agent_id=agent_id, + tool_name=tool_name, + tool_call_id=tool_call_id, + input=input, + extra_fields=fields, + ).to_dict() + ) + + def tool_result( + self, + *, + session_id: str | None = None, + agent_id: str | None = None, + tool_name: str, + tool_call_id: str, + output: Any | None = None, + error: str | None = None, + **fields, + ) -> None: + if "duration_ms" in fields: + raise ValueError("duration_ms is auto-computed by the SDK and cannot be passed by the caller") + self._validate_fields(fields) + session_id, agent_id = _resolve_identity(session_id, agent_id) + ts = self._now() + start_ts = self._pending.pop(_tool_key(session_id, tool_call_id), None) + duration_ms = _measured_duration_ms(start_ts, ts) + self._writer.submit( + ToolResultEvent( + timestamp=self._fmt_ts(ts), + session_id=session_id, + agent_id=agent_id, + tool_name=tool_name, + tool_call_id=tool_call_id, + output=output, + error=error, + duration_ms=duration_ms, + extra_fields=fields, + ).to_dict() + ) + + def model_request( + self, + *, + session_id: str | None = None, + agent_id: str | None = None, + model: str | None = None, + messages: list[dict] | None = None, + system: Any | None = None, + tools: list[dict] | None = None, + request_id: str | None = None, + **fields, + ) -> None: + self._validate_fields(fields) + session_id, agent_id = _resolve_identity(session_id, agent_id) + ts = self._now() + self._writer.submit( + ModelRequestEvent( + timestamp=self._fmt_ts(ts), + session_id=session_id, + agent_id=agent_id, + model=model, + messages=messages, + system=system, + tools=tools, + request_id=request_id, + extra_fields=fields, + ).to_dict() + ) + + def model_response( + self, + *, + session_id: str | None = None, + agent_id: str | None = None, + model: str | None = None, + stop_reason: str | None = None, + input_tokens: int | None = None, + output_tokens: int | None = None, + content: Any | None = None, + role: str | None = None, + request_id: str | None = None, + **fields, + ) -> None: + # Named parameters, so they never reach `_validate_fields`. They are also + # the likeliest of the three to arrive wrong: a caller reading them off a + # provider's usage object gets whatever that object holds. + _validate_promoted_numeric("input_tokens", input_tokens) + _validate_promoted_numeric("output_tokens", output_tokens) + self._validate_fields(fields) + session_id, agent_id = _resolve_identity(session_id, agent_id) + ts = self._now() + self._writer.submit( + ModelResponseEvent( + timestamp=self._fmt_ts(ts), + session_id=session_id, + agent_id=agent_id, + model=model, + stop_reason=stop_reason, + input_tokens=input_tokens, + output_tokens=output_tokens, + content=content, + role=role, + request_id=request_id, + extra_fields=fields, + ).to_dict() + ) + + def agent_start( + self, + *, + session_id: str | None = None, + agent_id: str | None = None, + goal: str | None = None, + parent_id: str | None = None, + **fields, + ) -> None: + self._validate_fields(fields) + session_id, agent_id = _resolve_identity(session_id, agent_id) + ts = self._now() + self._writer.submit( + AgentStartEvent( + timestamp=self._fmt_ts(ts), + session_id=session_id, + agent_id=agent_id, + goal=goal, + parent_id=parent_id, + extra_fields=fields, + ).to_dict() + ) + + def agent_end( + self, + *, + session_id: str | None = None, + agent_id: str | None = None, + outcome: str | None = None, + summary: str | None = None, + **fields, + ) -> None: + self._validate_fields(fields) + session_id, agent_id = _resolve_identity(session_id, agent_id) + ts = self._now() + self._writer.submit( + AgentEndEvent( + timestamp=self._fmt_ts(ts), + session_id=session_id, + agent_id=agent_id, + outcome=outcome, + summary=summary, + extra_fields=fields, + ).to_dict() + ) + + def agent_pause( + self, + *, + session_id: str | None = None, + agent_id: str | None = None, + pause_id: str, + reason: str | None = None, + user_id: str | None = None, + **fields, + ) -> None: + self._validate_fields(fields) + session_id, agent_id = _resolve_identity(session_id, agent_id) + ts = self._now() + self._track_pending(f"pause:{session_id}:{pause_id}", ts) + self._writer.submit( + AgentPauseEvent( + timestamp=self._fmt_ts(ts), + session_id=session_id, + agent_id=agent_id, + pause_id=pause_id, + reason=reason, + user_id=user_id, + extra_fields=fields, + ).to_dict() + ) + + def agent_resume( + self, + *, + session_id: str | None = None, + agent_id: str | None = None, + pause_id: str, + reason: str | None = None, + user_id: str | None = None, + **fields, + ) -> None: + if "duration_ms" in fields: + raise ValueError("duration_ms is auto-computed by the SDK and cannot be passed by the caller") + self._validate_fields(fields) + session_id, agent_id = _resolve_identity(session_id, agent_id) + ts = self._now() + start_ts = self._pending.pop(f"pause:{session_id}:{pause_id}", None) + duration_ms = _measured_duration_ms(start_ts, ts) + self._writer.submit( + AgentResumeEvent( + timestamp=self._fmt_ts(ts), + session_id=session_id, + agent_id=agent_id, + pause_id=pause_id, + duration_ms=duration_ms, + reason=reason, + user_id=user_id, + extra_fields=fields, + ).to_dict() + ) + + def hook_triggered( + self, + *, + session_id: str | None = None, + agent_id: str | None = None, + hook_name: str, + hook_id: str, + trigger_event: str | None = None, + input: Any | None = None, + **fields, + ) -> None: + self._validate_fields(fields) + session_id, agent_id = _resolve_identity(session_id, agent_id) + ts = self._now() + self._track_pending(_hook_key(session_id, hook_id), ts) + self._writer.submit( + HookTriggeredEvent( + timestamp=self._fmt_ts(ts), + session_id=session_id, + agent_id=agent_id, + hook_name=hook_name, + hook_id=hook_id, + trigger_event=trigger_event, + input=input, + extra_fields=fields, + ).to_dict() + ) + + def hook_completed( + self, + *, + session_id: str | None = None, + agent_id: str | None = None, + hook_name: str, + hook_id: str, + outcome: str | None = None, + output: Any | None = None, + error: str | None = None, + **fields, + ) -> None: + if "duration_ms" in fields: + raise ValueError("duration_ms is auto-computed by the SDK and cannot be passed by the caller") + self._validate_fields(fields) + session_id, agent_id = _resolve_identity(session_id, agent_id) + ts = self._now() + start_ts = self._pending.pop(_hook_key(session_id, hook_id), None) + duration_ms = _measured_duration_ms(start_ts, ts) + self._writer.submit( + HookCompletedEvent( + timestamp=self._fmt_ts(ts), + session_id=session_id, + agent_id=agent_id, + hook_name=hook_name, + hook_id=hook_id, + outcome=outcome, + output=output, + error=error, + duration_ms=duration_ms, + extra_fields=fields, + ).to_dict() + ) + + def error( + self, + *, + session_id: str | None = None, + agent_id: str | None = None, + error_type: str, + message: str, + traceback: str | None = None, + **fields, + ) -> None: + self._validate_fields(fields) + session_id, agent_id = _resolve_identity(session_id, agent_id) + ts = self._now() + self._writer.submit( + ErrorEvent( + timestamp=self._fmt_ts(ts), + session_id=session_id, + agent_id=agent_id, + error_type=error_type, + message=message, + traceback=traceback, + extra_fields=fields, + ).to_dict() + ) + + def human_wait( + self, + *, + session_id: str | None = None, + agent_id: str | None = None, + input_id: str, + prompt: str | None = None, + options: list[str] | None = None, + reason: str | None = None, + **fields, + ) -> None: + self._validate_fields(fields) + session_id, agent_id = _resolve_identity(session_id, agent_id) + ts = self._now() + self._track_pending(f"human:{session_id}:{input_id}", ts) + self._writer.submit( + HumanWaitEvent( + timestamp=self._fmt_ts(ts), + session_id=session_id, + agent_id=agent_id, + input_id=input_id, + prompt=prompt, + options=options, + reason=reason, + extra_fields=fields, + ).to_dict() + ) + + def human_input( + self, + *, + session_id: str | None = None, + agent_id: str | None = None, + input_id: str, + response: str | None = None, + **fields, + ) -> None: + if "duration_ms" in fields: + raise ValueError("duration_ms is auto-computed by the SDK and cannot be passed by the caller") + self._validate_fields(fields) + session_id, agent_id = _resolve_identity(session_id, agent_id) + ts = self._now() + start_ts = self._pending.pop(f"human:{session_id}:{input_id}", None) + duration_ms = _measured_duration_ms(start_ts, ts) + self._writer.submit( + HumanInputEvent( + timestamp=self._fmt_ts(ts), + session_id=session_id, + agent_id=agent_id, + input_id=input_id, + response=response, + duration_ms=duration_ms, + extra_fields=fields, + ).to_dict() + ) + + def human_pause( + self, + *, + session_id: str | None = None, + agent_id: str | None = None, + reason: str | None = None, + user_id: str | None = None, + **fields, + ) -> None: + self._validate_fields(fields) + session_id, agent_id = _resolve_identity(session_id, agent_id) + ts = self._now() + self._writer.submit( + HumanPauseEvent( + timestamp=self._fmt_ts(ts), + session_id=session_id, + agent_id=agent_id, + reason=reason, + user_id=user_id, + extra_fields=fields, + ).to_dict() + ) + + def human_interrupt( + self, + *, + session_id: str | None = None, + agent_id: str | None = None, + reason: str | None = None, + user_id: str | None = None, + at_step: str | None = None, + **fields, + ) -> None: + self._validate_fields(fields) + session_id, agent_id = _resolve_identity(session_id, agent_id) + ts = self._now() + self._writer.submit( + HumanInterruptEvent( + timestamp=self._fmt_ts(ts), + session_id=session_id, + agent_id=agent_id, + reason=reason, + user_id=user_id, + at_step=at_step, + extra_fields=fields, + ).to_dict() + ) diff --git a/sdk/python/failproofai_sdk/_resolver.py b/sdk/python/failproofai_sdk/_resolver.py new file mode 100644 index 000000000..8398ddda5 --- /dev/null +++ b/sdk/python/failproofai_sdk/_resolver.py @@ -0,0 +1,101 @@ +import os +from pathlib import Path + +_base_dir: Path | None = None + + +def get_base_dir() -> Path: + """Where this SDK writes its event spool. + + Resolution order, most explicit first: + + 1. ``set_base_dir()`` — a caller said so outright + 2. ``$AGENTEYE_HOME`` — an operator said so + 3. ``~/.failproofai/custom-agents`` — the default + + THE DEFAULT MOVED, AND THE OLD ROOT IS STILL READ. + + It used to be ``~/.agenteye``, with the umbrella reachable only behind an + ``AGENTEYE_SPOOL_TO_FAILPROOFAI`` opt-in that also required the directory to + already exist. Nothing created that directory — not this SDK, not + ``failproofaid``, not either installer — so the second condition was never + true and the opt-in never fired. The umbrella was documented, tested, and + unreachable. + + The daemon this SDK ships beside, ``failproofaid``, watches BOTH roots and + always has (``crates/fpai-collect/src/config.rs`` builds ``spool_dirs`` from + ``custom_agents_events_dir()`` AND ``agenteye_events_dir()``, and both stay + watched indefinitely). So on a host running it, this change moves where the + files land and nothing else: they are collected either way. + + **Batches already sitting in ``~/.agenteye/events`` are not orphaned.** They + stay where they are and are still drained by whichever collector owns that + root. The directory simply stops growing. Nothing needs to be moved by hand. + + ## The one case that breaks, and its escape hatch + + ``agenteye-collector`` — the older daemon in the private AgentEye repository + — resolves its base from ``$AGENTEYE_HOME`` or ``~/.agenteye`` and NOTHING + else (``collector/src/config.rs``, ``base_dir()``). It has no idea the + umbrella exists. On a host running that collector and this SDK, the default + below writes where it does not look, and the failure is silent: no error on + either side, batches accumulate forever, and an unread spool is + indistinguishable from an idle one. + + That host sets:: + + AGENTEYE_HOME=~/.agenteye + + which is step 2 above and predates this change. It is the documented escape + hatch precisely because both daemons already honour it, so it cannot itself + desynchronise them. + + ``test_spool_contract.py`` reads the Rust and the TypeScript that define + these roots and fails if either drifts from what this module resolves. + """ + if _base_dir is not None: + return _base_dir + + env_override = os.environ.get("AGENTEYE_HOME") + if env_override: + return Path(env_override) + + return failproofai_custom_agents_dir() + + +def failproofai_custom_agents_dir() -> Path: + """``~/.failproofai/custom-agents``, honouring ``$FAILPROOFAI_HOME``. + + Mirrors ``customAgentsDir()`` in ``src/hooks/fp-home.ts`` and + ``custom_agents_events_dir()`` in ``crates/fpai-collect/src/config.rs``. The + three must agree; a divergence would mean this SDK writes somewhere the + daemon never reads. + + All three live in THIS repository, so the agreement is checkable rather than + hoped for — ``tests/test_spool_contract.py`` reads the Rust and the + TypeScript and fails if either drifts. + + Returns a path unconditionally and never checks whether it exists. The + caller creates it: ``_writer._write_batch`` already does + ``mkdir(parents=True, exist_ok=True)`` on the directory it is about to write + into. An existence check here is what made the old opt-in dead — a spool + root that must pre-exist can never be the place a first batch is written. + """ + fp_home = os.environ.get("FAILPROOFAI_HOME") + base = Path(fp_home) if fp_home else Path.home() / ".failproofai" + return base / "custom-agents" + + +def legacy_agenteye_dir() -> Path: + """``~/.agenteye`` — the root this SDK wrote to before the default moved. + + Not part of resolution any more. Kept as a named constant because the + migration notes, the tests and the ``AGENTEYE_HOME`` escape hatch all refer + to it, and spelling it in four places is how the two sides drift apart. + """ + return Path.home() / ".agenteye" + + +def set_base_dir(path: "str | Path | None") -> None: + global _base_dir + _base_dir = Path(path) if path is not None else None diff --git a/sdk/python/failproofai_sdk/_runtime.py b/sdk/python/failproofai_sdk/_runtime.py new file mode 100644 index 000000000..b606ae7ce --- /dev/null +++ b/sdk/python/failproofai_sdk/_runtime.py @@ -0,0 +1,21 @@ +"""The process-wide writer and event namespace. + +These used to be constructed in `failproofai_sdk/__init__.py`. They live in a +leaf module now so that `_scopes` and the framework adapters can reach the +namespace without importing the `failproofai_sdk` package itself, which would be +a circular import. + +Constructing `EventWriter` starts a daemon thread and registers the module-level +atexit flush, and that still happens at `import failproofai_sdk` time — +`__init__.py` imports this module, so the timing is unchanged. + +Reach the namespace as `_runtime.event`, an attribute lookup at call time rather +than a `from ... import event` binding, so a test can swap in a recording +namespace and the scopes pick it up. +""" + +from failproofai_sdk._events import EventNamespace +from failproofai_sdk._writer import EventWriter + +writer = EventWriter() +event = EventNamespace(writer) diff --git a/sdk/python/failproofai_sdk/_schema.py b/sdk/python/failproofai_sdk/_schema.py new file mode 100644 index 000000000..c16d9ba9a --- /dev/null +++ b/sdk/python/failproofai_sdk/_schema.py @@ -0,0 +1,317 @@ +from dataclasses import dataclass, field +from typing import Any + +from failproofai_sdk._environment import get_environment + + +def _build(base: dict, specifics: list[tuple[str, Any]], extra: dict) -> dict: + """Build ordered event dict, omitting None values, then merge extra fields.""" + result = {**base} + result["environment"] = get_environment() + for k, v in specifics: + if v is not None: + result[k] = v + result.update(extra) + return result + + +@dataclass(kw_only=True) +class ToolUseEvent: + timestamp: str + session_id: str + agent_id: str + tool_name: str + tool_call_id: str + input: dict | None = None + extra_fields: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return _build( + {"timestamp": self.timestamp, "session_id": self.session_id, "agent_id": self.agent_id, "type": "tool_use", + "tool_name": self.tool_name, "tool_call_id": self.tool_call_id}, + [("input", self.input)], + self.extra_fields, + ) + + +@dataclass(kw_only=True) +class ToolResultEvent: + timestamp: str + session_id: str + agent_id: str + tool_name: str + tool_call_id: str + output: Any | None = None + error: str | None = None + duration_ms: float | None = None + extra_fields: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return _build( + {"timestamp": self.timestamp, "session_id": self.session_id, "agent_id": self.agent_id, "type": "tool_result", + "tool_name": self.tool_name, "tool_call_id": self.tool_call_id}, + [("output", self.output), ("error", self.error), ("duration_ms", self.duration_ms)], + self.extra_fields, + ) + + +@dataclass(kw_only=True) +class ModelRequestEvent: + timestamp: str + session_id: str + agent_id: str + model: str | None = None + messages: list[dict] | None = None + system: Any | None = None + tools: list[dict] | None = None + #: Pairs this request with its response. Appended LAST in `_build`'s ordered + #: list so an event that omits it serialises byte-for-byte as before — + #: `test_wire_format.py` freezes those bytes, and the dedup key hashes them. + request_id: str | None = None + extra_fields: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return _build( + {"timestamp": self.timestamp, "session_id": self.session_id, "agent_id": self.agent_id, "type": "model_request"}, + [("model", self.model), ("messages", self.messages), + ("system", self.system), ("tools", self.tools), + ("request_id", self.request_id)], + self.extra_fields, + ) + + +@dataclass(kw_only=True) +class ModelResponseEvent: + timestamp: str + session_id: str + agent_id: str + model: str | None = None + stop_reason: str | None = None + input_tokens: int | None = None + output_tokens: int | None = None + content: Any | None = None + role: str | None = None + #: The `request_id` of the `model_request` this answers. See above. + request_id: str | None = None + extra_fields: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return _build( + {"timestamp": self.timestamp, "session_id": self.session_id, "agent_id": self.agent_id, "type": "model_response"}, + [("model", self.model), ("stop_reason", self.stop_reason), + ("input_tokens", self.input_tokens), ("output_tokens", self.output_tokens), + ("content", self.content), ("role", self.role), + ("request_id", self.request_id)], + self.extra_fields, + ) + + +@dataclass(kw_only=True) +class AgentStartEvent: + timestamp: str + session_id: str + agent_id: str + goal: str | None = None + parent_id: str | None = None + extra_fields: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return _build( + {"timestamp": self.timestamp, "session_id": self.session_id, "agent_id": self.agent_id, "type": "agent_start"}, + [("goal", self.goal), ("parent_id", self.parent_id)], + self.extra_fields, + ) + + +@dataclass(kw_only=True) +class AgentEndEvent: + timestamp: str + session_id: str + agent_id: str + outcome: str | None = None + summary: str | None = None + extra_fields: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return _build( + {"timestamp": self.timestamp, "session_id": self.session_id, "agent_id": self.agent_id, "type": "agent_end"}, + [("outcome", self.outcome), ("summary", self.summary)], + self.extra_fields, + ) + + +@dataclass(kw_only=True) +class AgentPauseEvent: + timestamp: str + session_id: str + agent_id: str + pause_id: str + reason: str | None = None + user_id: str | None = None + extra_fields: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return _build( + {"timestamp": self.timestamp, "session_id": self.session_id, "agent_id": self.agent_id, + "type": "agent_pause", "pause_id": self.pause_id}, + [("reason", self.reason), ("user_id", self.user_id)], + self.extra_fields, + ) + + +@dataclass(kw_only=True) +class AgentResumeEvent: + timestamp: str + session_id: str + agent_id: str + pause_id: str + duration_ms: float | None = None + reason: str | None = None + user_id: str | None = None + extra_fields: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return _build( + {"timestamp": self.timestamp, "session_id": self.session_id, "agent_id": self.agent_id, + "type": "agent_resume", "pause_id": self.pause_id}, + [("duration_ms", self.duration_ms), ("reason", self.reason), ("user_id", self.user_id)], + self.extra_fields, + ) + + +@dataclass(kw_only=True) +class HookTriggeredEvent: + timestamp: str + session_id: str + agent_id: str + hook_name: str + hook_id: str + trigger_event: str | None = None + input: Any | None = None + extra_fields: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return _build( + {"timestamp": self.timestamp, "session_id": self.session_id, "agent_id": self.agent_id, "type": "hook_triggered", + "hook_name": self.hook_name, "hook_id": self.hook_id}, + [("trigger_event", self.trigger_event), ("input", self.input)], + self.extra_fields, + ) + + +@dataclass(kw_only=True) +class HookCompletedEvent: + timestamp: str + session_id: str + agent_id: str + hook_name: str + hook_id: str + outcome: str | None = None + output: Any | None = None + error: str | None = None + duration_ms: float | None = None + extra_fields: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return _build( + {"timestamp": self.timestamp, "session_id": self.session_id, "agent_id": self.agent_id, "type": "hook_completed", + "hook_name": self.hook_name, "hook_id": self.hook_id}, + [("outcome", self.outcome), ("output", self.output), + ("error", self.error), ("duration_ms", self.duration_ms)], + self.extra_fields, + ) + + +@dataclass(kw_only=True) +class ErrorEvent: + timestamp: str + session_id: str + agent_id: str + error_type: str + message: str + traceback: str | None = None + extra_fields: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return _build( + {"timestamp": self.timestamp, "session_id": self.session_id, "agent_id": self.agent_id, "type": "error", + "error_type": self.error_type, "message": self.message}, + [("traceback", self.traceback)], + self.extra_fields, + ) + + +@dataclass(kw_only=True) +class HumanWaitEvent: + timestamp: str + session_id: str + agent_id: str + input_id: str + prompt: str | None = None + options: list[str] | None = None + reason: str | None = None + extra_fields: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return _build( + {"timestamp": self.timestamp, "session_id": self.session_id, "agent_id": self.agent_id, + "type": "human_wait", "input_id": self.input_id}, + [("prompt", self.prompt), ("options", self.options), ("reason", self.reason)], + self.extra_fields, + ) + + +@dataclass(kw_only=True) +class HumanInputEvent: + timestamp: str + session_id: str + agent_id: str + input_id: str + response: str | None = None + duration_ms: float | None = None + extra_fields: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return _build( + {"timestamp": self.timestamp, "session_id": self.session_id, "agent_id": self.agent_id, + "type": "human_input", "input_id": self.input_id}, + [("response", self.response), ("duration_ms", self.duration_ms)], + self.extra_fields, + ) + + +@dataclass(kw_only=True) +class HumanPauseEvent: + timestamp: str + session_id: str + agent_id: str + reason: str | None = None + user_id: str | None = None + extra_fields: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return _build( + {"timestamp": self.timestamp, "session_id": self.session_id, "agent_id": self.agent_id, + "type": "human_pause"}, + [("reason", self.reason), ("user_id", self.user_id)], + self.extra_fields, + ) + + +@dataclass(kw_only=True) +class HumanInterruptEvent: + timestamp: str + session_id: str + agent_id: str + reason: str | None = None + user_id: str | None = None + at_step: str | None = None + extra_fields: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return _build( + {"timestamp": self.timestamp, "session_id": self.session_id, "agent_id": self.agent_id, + "type": "human_interrupt"}, + [("reason", self.reason), ("user_id", self.user_id), ("at_step", self.at_step)], + self.extra_fields, + ) diff --git a/sdk/python/failproofai_sdk/_scopes.py b/sdk/python/failproofai_sdk/_scopes.py new file mode 100644 index 000000000..e710a4268 --- /dev/null +++ b/sdk/python/failproofai_sdk/_scopes.py @@ -0,0 +1,364 @@ +"""Context-manager scopes: `session()`, `agent()`, `tool_call()`. + +These are the ergonomic surface over `failproofai_sdk.event.*`. Each one binds run +identity onto the contextvars in `failproofai_sdk._context` so that everything emitted +inside the block — including code that has never heard of the SDK's identity +arguments — lands on the right session and agent. + +Why classes and not `@contextlib.contextmanager` +------------------------------------------------ +A `@contextmanager` generator supports `with` only. An agent framework is +half-async, so the same scope has to work under `async with` too, and +`@asynccontextmanager` would mean a second, separately-maintained copy of the +body. These are plain classes with `__enter__`/`__exit__` **and** +`__aenter__`/`__aexit__`, where the async pair delegates to the sync pair. No +scope awaits anything — `writer.submit()` is a `deque.append` — so the delegation +is not a lie, and `async with` provably produces byte-identical events. + +The namespace is reached as `_runtime.event` (attribute lookup at call time, not +a `from ... import event` binding) both to avoid importing `failproofai_sdk` from inside +`failproofai_sdk` and so tests can swap in a recording namespace. +""" + +import contextvars +import sys +import traceback as _traceback +import uuid +from typing import Any, Literal + +from failproofai_sdk import _context +from failproofai_sdk import _runtime + + +class _Auto: + """Sentinel for `agent(parent_id=...)`. + + `parent_id` has three states and `None` is a meaningful one of them, so the + default cannot be `None`: + + * `AUTO` — infer the enclosing agent from the context stack (the default); + * `None` — force a root span, emitting no `parent_id` at all; + * `"str"` — use this id verbatim. + """ + + __slots__ = () + + def __repr__(self) -> str: # pragma: no cover - debugging aid + return "AUTO" + + +AUTO = _Auto() + + +def _is_cancellation(exc_type: type) -> bool: + """True for `asyncio.CancelledError` / `GeneratorExit`. + + A cancellation is not a failure: it must not emit an `error` event, or every + cancelled run pollutes the Errors surface. `asyncio` is looked up through + `sys.modules` rather than imported, so `import failproofai_sdk` stays cheap — if + `asyncio` was never imported, nothing in the process can have raised its + `CancelledError`. + """ + if issubclass(exc_type, GeneratorExit): + return True + asyncio = sys.modules.get("asyncio") + return asyncio is not None and issubclass(exc_type, asyncio.CancelledError) + + +def _describe(exc_type: type, exc: BaseException | None) -> str: + text = str(exc) if exc is not None else "" + return f"{exc_type.__name__}: {text}" if text else exc_type.__name__ + + +class session: + """Bind a session id (and optionally an agent id) for the enclosing block. + + with failproofai_sdk.session() as sid: + failproofai_sdk.event.agent_start(agent_id="main", goal="...") + + Emits **no events** — it is identity only. `agent()` is what brackets a run + with `agent_start`/`agent_end`. + + `session_id=None` reuses an already-bound session if there is one, and + otherwise generates `uuid4().hex`. That inheritance is what lets a nested + scope stay inside one run instead of splitting it into two sessions. + """ + + __slots__ = ("_requested", "_agent_id", "id", "_sid_token", "_agent_token") + + def __init__(self, session_id: str | None = None, *, agent_id: str | None = None) -> None: + self._requested = session_id + self._agent_id = agent_id + self.id: str | None = None + self._sid_token: "contextvars.Token | None" = None + self._agent_token: "contextvars.Token | None" = None + + def _enter(self) -> str: + sid = self._requested or _context.session_id() or uuid.uuid4().hex + self.id = sid + self._sid_token = _context.bind_session(sid) + if self._agent_id is not None: + self._agent_token = _context.push_agent(self._agent_id) + return sid + + def _exit(self) -> None: + # Unwind in reverse, and unconditionally: a scope that leaks an agent + # frame misattributes every later event in the process. + _context.reset(self._agent_token) + _context.reset(self._sid_token) + self._agent_token = None + self._sid_token = None + + def __enter__(self) -> str: + return self._enter() + + # `Literal[False]`, not `bool`: a `__exit__` typed `bool` tells every caller's + # type checker this scope MIGHT swallow the exception. It never does. + def __exit__(self, exc_type, exc, tb) -> Literal[False]: + self._exit() + return False + + async def __aenter__(self) -> str: + return self._enter() + + async def __aexit__(self, exc_type, exc, tb) -> Literal[False]: + self._exit() + return False + + +class agent: + """Bracket a run (or a sub-run) with `agent_start` / `agent_end`. + + with failproofai_sdk.agent("planner", goal=question): + ... + + `agent_id` is positional on purpose. It is the one argument integrators type + on every single call site, and the rest of the SDK's keyword-only discipline + exists to stop *identity* arguments being passed by accident — which is not a + risk here. + + Keep `agent_id` low-cardinality (a node/role name, never a UUID): it is a + `LowCardinality(String)` column and the primary facet across every session. + + `**fields` are attached to `agent_start` only; `agent_end` carries `outcome` + and `summary`. + + Exit semantics, which are the whole point of the class: + + | exception | events | outcome | + |---------------------------------|-------------------|-------------| + | none | `agent_end` | `outcome=` | + | `Exception` | `error`, then end | `"failed"` | + | `KeyboardInterrupt`/`SystemExit`| `error`, then end | `"failed"` | + | `CancelledError`/`GeneratorExit`| `agent_end` only | `"cancelled"`| + + `error` strictly *before* `agent_end`, because the dashboard closes the agent + span at `agent_end` and anything after it is attributed to nothing. The + literal is `"failed"`, never `"failure"` — only `error|failed|timeout|rejected` + count as a failure server-side. The exception is always re-raised. + """ + + __slots__ = ( + "agent_id", + "_requested_sid", + "_goal", + "_parent_id", + "_outcome", + "_summary", + "_fields", + "session_id", + "_sid_token", + "_agent_token", + ) + + def __init__( + self, + agent_id: str = "main", + *, + session_id: str | None = None, + goal: str | None = None, + parent_id: "str | None | _Auto" = AUTO, + outcome: str | None = "success", + summary: str | None = None, + **fields: Any, + ) -> None: + self.agent_id = agent_id + self._requested_sid = session_id + self._goal = goal + self._parent_id = parent_id + self._outcome = outcome + self._summary = summary + self._fields = fields + self.session_id: str | None = None + self._sid_token: "contextvars.Token | None" = None + self._agent_token: "contextvars.Token | None" = None + + def _enter(self) -> _context.Identity: + sid = self._requested_sid or _context.session_id() or uuid.uuid4().hex + self.session_id = sid + + if isinstance(self._parent_id, _Auto): + parent = _context.current().agent_id # None when nothing is open + else: + parent = self._parent_id + + self._sid_token = _context.bind_session(sid) + self._agent_token = _context.push_agent(self.agent_id) + try: + _runtime.event.agent_start( + session_id=sid, + agent_id=self.agent_id, + goal=self._goal, + parent_id=parent, + **self._fields, + ) + except BaseException: + # A rejected agent_start (a reserved **field, say) must not leave a + # half-entered scope behind: `__exit__` never runs if `__enter__` + # raises. + self._unwind() + raise + return _context.current() + + def _unwind(self) -> None: + _context.reset(self._agent_token) + _context.reset(self._sid_token) + self._agent_token = None + self._sid_token = None + + def _exit(self, exc_type, exc, tb) -> bool: + try: + if exc_type is None: + outcome = self._outcome + elif _is_cancellation(exc_type): + outcome = "cancelled" + else: + outcome = "failed" + _runtime.event.error( + session_id=self.session_id, + agent_id=self.agent_id, + error_type=exc_type.__name__, + message=str(exc) if exc is not None else "", + traceback="".join(_traceback.format_exception(exc_type, exc, tb)), + ) + _runtime.event.agent_end( + session_id=self.session_id, + agent_id=self.agent_id, + outcome=outcome, + summary=self._summary, + ) + finally: + # In `finally` so the stack is intact even if emission itself blew + # up. A leaked frame is worse than a lost event. + self._unwind() + return False + + def __enter__(self) -> _context.Identity: + return self._enter() + + def __exit__(self, exc_type, exc, tb) -> bool: + return self._exit(exc_type, exc, tb) + + async def __aenter__(self) -> _context.Identity: + return self._enter() + + async def __aexit__(self, exc_type, exc, tb) -> bool: + return self._exit(exc_type, exc, tb) + + +class ToolCall: + """The handle `tool_call()` yields. Set `.output`; read `.id`.""" + + __slots__ = ("_id", "output") + + def __init__(self, tool_call_id: str) -> None: + self._id = tool_call_id + self.output: Any = None + + @property + def id(self) -> str: + """The correlation id on both the `tool_use` and the `tool_result`.""" + return self._id + + def __repr__(self) -> str: # pragma: no cover - debugging aid + return f"ToolCall(id={self._id!r})" + + +class tool_call: + """Bracket a tool invocation with `tool_use` / `tool_result`. + + with failproofai_sdk.tool_call("web_search", input={"q": q}) as t: + t.output = search(q) + + `tool_call_id` defaults to `uuid4().hex`. Identity comes from the enclosing + scope; if nothing is bound, the underlying `event.tool_use()` raises the + usual `TypeError` naming the fix. + + On failure this emits `tool_result(error="TypeName: msg")` and **no `error` + event**. A tool failure the agent loop catches is not a run-level error, and + one that propagates is reported exactly once, by the enclosing `agent()`. + Cancellation closes the leaf with no `error` string at all, for the same + reason `agent()` does not mark it failed. + """ + + __slots__ = ("tool_name", "_requested_id", "_input", "_fields", "_box", "_sid", "_aid") + + def __init__( + self, + tool_name: str, + *, + tool_call_id: str | None = None, + input: dict | None = None, + **fields: Any, + ) -> None: + self.tool_name = tool_name + self._requested_id = tool_call_id + self._input = input + self._fields = fields + self._box: ToolCall | None = None + self._sid: str | None = None + self._aid: str | None = None + + def _enter(self) -> ToolCall: + # Resolve once, at entry: a tool that pushes its own scope inside must + # not make the closing tool_result land on a different agent_id. + self._sid = _context.session_id() + self._aid = _context.agent_id() + box = ToolCall(self._requested_id or uuid.uuid4().hex) + self._box = box + _runtime.event.tool_use( + session_id=self._sid, + agent_id=self._aid, + tool_name=self.tool_name, + tool_call_id=box.id, + input=self._input, + **self._fields, + ) + return box + + def _exit(self, exc_type, exc, tb) -> bool: + assert self._box is not None + error = None + if exc_type is not None and not _is_cancellation(exc_type): + error = _describe(exc_type, exc) + _runtime.event.tool_result( + session_id=self._sid, + agent_id=self._aid, + tool_name=self.tool_name, + tool_call_id=self._box.id, + output=self._box.output, + error=error, + ) + return False + + def __enter__(self) -> ToolCall: + return self._enter() + + def __exit__(self, exc_type, exc, tb) -> bool: + return self._exit(exc_type, exc, tb) + + async def __aenter__(self) -> ToolCall: + return self._enter() + + async def __aexit__(self, exc_type, exc, tb) -> bool: + return self._exit(exc_type, exc, tb) diff --git a/sdk/python/failproofai_sdk/_version.py b/sdk/python/failproofai_sdk/_version.py new file mode 100644 index 000000000..aeb64063a --- /dev/null +++ b/sdk/python/failproofai_sdk/_version.py @@ -0,0 +1 @@ +__version__ = "0.0.1b14" diff --git a/sdk/python/failproofai_sdk/_writer.py b/sdk/python/failproofai_sdk/_writer.py new file mode 100644 index 000000000..1f4e59cba --- /dev/null +++ b/sdk/python/failproofai_sdk/_writer.py @@ -0,0 +1,503 @@ +import atexit +import collections +import itertools +import json +import logging +import math +import os +import threading +import weakref +from datetime import datetime, timezone + +from failproofai_sdk._resolver import get_base_dir + + +logger = logging.getLogger(__name__) + +#: Per-process batch counter, so two batches written inside the same millisecond +#: cannot land on the same filename. +#: +#: The timestamp alone was not enough, and the way it failed was invisible. Two +#: batches in the same millisecond produced the same stem, and the second +#: `os.replace` overwrote the first — no exception, no log line, no trace that +#: the events had ever existed. Three routine situations hit it: +#: +#: * the atexit flush racing the flush thread's own final cycle, which is +#: exactly when the last events of a run are written; +#: * any caller invoking `flush_now()` from more than one thread; +#: * several agent processes sharing one spool root — the normal deployment. +#: Nothing in the stem identified the writer, so unrelated processes +#: silently overwrote each other's batches. +#: +#: Hence the pid as well as the counter: the counter fixes the in-process race +#: and the pid fixes the cross-process one. The daemons require only that a +#: batch file end in `.jsonl` and not `.tmp` (`collector/src/watcher.rs` in +#: AgentEye, `crates/fpai-collect/src/spool.rs` here), so the rest of the stem +#: is ours to make unique — and fpai-collect's own batches carry a run id and a +#: sequence number for the same reason. +_batch_seq = itertools.count() + +#: Hard cap on the in-memory queue, matching `_events._PENDING_CAP`. +#: +#: `submit()` is called from the caller's own agent loop and must never block or +#: raise, so it cannot apply backpressure — which leaves an unbounded queue as +#: the only other option, and that is a memory leak wearing a different hat. Any +#: condition that stops the spool draining (a full disk, a read-only mount, a +#: forked child before this module learned to restart its thread) then converts +#: a telemetry outage into an OOM kill of the host agent. Losing the oldest +#: events is the better failure: it is bounded, it is logged, and the events +#: most worth having are the recent ones. +#: +#: 10_000 events is roughly 10 MB of dicts, and at the default 500 ms interval a +#: process would have to emit 20_000 events/second to reach it. Anything that +#: does hit this cap is not a busy agent, it is a spool that has stopped. +_QUEUE_CAP = 10_000 + +#: How deep `_sanitize` will walk before giving up on a branch. Guards the +#: fallback path against a RecursionError, which would defeat the point of +#: having a fallback at all. +_MAX_SANITIZE_DEPTH = 50 + +#: How `json.dumps(ensure_ascii=True)` writes a lone surrogate. Cheap to scan +#: for, and the only in-band signal that one is present — encoding never fails. +_SURROGATE_ESCAPE = "\\ud" + +_CYCLE_MARKER = "<circular reference>" +_DEPTH_MARKER = "<max depth exceeded>" + +#: Every live writer, weakly. Both the fork handler and the atexit flush iterate +#: this rather than binding to one instance, which is what lets the atexit hook +#: be registered once at module scope instead of once per writer — +#: `atexit.register(self._flush)` stored a strong reference to a bound method and +#: so made every EventWriter ever built immortal. +#: +#: Weak references do NOT make a writer collectable on their own: its flush +#: thread targets `self._flush_loop`, and a running thread holds its target. So +#: in practice a writer lives as long as its thread does, which is for the life +#: of the process. The weakness earns its keep on the fork path, where a dead +#: referent must be skipped rather than restarted, and it stops this list being a +#: second, independent reason a writer can never go away. +_live_writers: "list[weakref.ref[EventWriter]]" = [] + + +def _validated_interval(flush_interval: float) -> float: + """A flush interval `_flush_loop` can actually wait on. + + The wait happens BEFORE the loop's try/except, deliberately — a flush that + raises must be retried next cycle, and wrapping the wait would mean a bad + interval retries forever at full speed instead. The cost of that choice is + that an unwaitable interval kills the thread outright, and the thread dying + is the worst failure this class has: `submit()` keeps accepting events, the + queue fills to `_QUEUE_CAP` and then starts discarding, and the caller sees + no error until the process exits and takes everything with it. + + So the value is rejected at the boundary instead, where a caller still has a + stack trace pointing at their own `configure()` call: + + -1 -> ValueError from Event.wait, thread dies + nan -> ValueError from Event.wait, thread dies + inf -> OverflowError from Event.wait, thread dies + 0 -> waits not at all; a busy loop pinning a core and rewriting the + spool as fast as the disk allows + """ + interval = float(flush_interval) + if not math.isfinite(interval) or interval <= 0: + raise ValueError( + f"flush_interval must be a finite number greater than zero, got {flush_interval!r}" + ) + return interval + + +def _sanitize(value, seen: frozenset, depth: int = 0): + """Rewrite one payload into something `json.dumps` can definitely encode. + + Only ever reached from `_encode_entry`'s fallback, so it may be slow; it + must not be lossy in the ordinary case, and it must not raise. + + `seen` tracks the ids on the CURRENT PATH, not every id visited. A payload + that mentions the same dict twice as siblings is a DAG, not a cycle, and + json encodes it fine — flagging it would corrupt a perfectly good event. + """ + if depth > _MAX_SANITIZE_DEPTH: + return _DEPTH_MARKER + # NaN / inf / -inf. `json.dumps` writes these as the bare tokens NaN, + # Infinity and -Infinity, which are a Python extension and not valid JSON — + # a strict NDJSON reader rejects the line, and the whole event is lost for + # one field. There is no in-band JSON value for them, so None it is: the + # field reads as absent rather than as a number that is not one. + if isinstance(value, float) and not math.isfinite(value): + return None + # Lone surrogates. `os.fsdecode` and `bytes.decode(errors="surrogateescape")` + # — the standard way Python carries bytes that are not valid UTF-8, and what + # a filesystem path or a truncated tool output arrives as — produce these. + # `json.dumps` escapes them happily as \udcff, so nothing fails locally, and + # then the SERVER skips the whole event: verified against a live ingest, + # `{"accepted":0,"skipped":1}` at 200 OK. `backslashreplace` keeps the byte + # visible in the payload instead of dropping it to a `?`. + if isinstance(value, str): + return value.encode("utf-8", "backslashreplace").decode("utf-8") + if isinstance(value, dict): + if id(value) in seen: + return _CYCLE_MARKER + seen = seen | {id(value)} + # Non-str keys are the common half of this bug: `json.dumps(default=...)` + # is never consulted for keys, so a tuple-keyed cache raises TypeError + # no matter what default is passed. + return { + (k if isinstance(k, str) else str(k)): _sanitize(v, seen, depth + 1) + for k, v in value.items() + } + if isinstance(value, (list, tuple)): + if id(value) in seen: + return _CYCLE_MARKER + seen = seen | {id(value)} + return [_sanitize(v, seen, depth + 1) for v in value] + return value + + +def _encode_entry(entry: dict) -> "str | None": + """One event as a JSON line, or None if it cannot be encoded at all. + + THE POINT IS ISOLATION. This used to be a single `json.dumps` over the whole + batch, which meant one unencodable payload took every event beside it down: + `_flush` restored the batch and re-raised, `_flush_loop` logged and retried + the identical batch on the next interval, and the spool never advanced again. + A tuple-keyed dict or an object holding a back-reference — both ordinary + things to hand a telemetry call — permanently ended recording for the + process, and the only outward sign was a traceback at exit. + + `default=str` does not prevent it. It is consulted for unsupported *values* + only, so it rescues datetime and UUID but not a non-str key and not a cycle: + + {"k": {(1, 2): "v"}} -> TypeError: keys must be str, int, float, ... + d = {}; d["self"] = d -> ValueError: Circular reference detected + + `allow_nan=False` is part of "strict" here. Left at its default, `json.dumps` + emits the bare tokens `NaN`, `Infinity` and `-Infinity` — a Python extension + that is NOT valid JSON and that a strict NDJSON reader rejects. Worse, it + does not raise, so the fallback below never ran and the malformed line went + out looking fine. With it off, a non-finite float raises like any other + unencodable value and `_sanitize` maps it to None. + + So: try strict first (the fast path, byte-identical to what shipped before + for every payload that was already valid JSON), fall back to a sanitised + copy, and only then give up on that ONE event. + """ + # `except Exception`, not a list of the three encoder errors. `default=str` + # runs the CALLER'S `__repr__`/`__str__`, which can raise anything at all — + # a RuntimeError out of a lazy ORM attribute, an OSError out of a property + # that touches the network. Those escaped the narrow clause, propagated out + # of `_write_batch`, and put the whole batch back on the queue to be retried + # identically forever: the exact wedge this function exists to prevent, + # reached through a different exception type. + # + # BaseException is deliberately NOT caught — a KeyboardInterrupt during a + # flush must still interrupt. + try: + encoded = json.dumps(entry, default=str, allow_nan=False) + except Exception: + encoded = None + + if encoded is not None: + # `ensure_ascii` is on, so a lone surrogate leaves here as the literal + # text \udXXX rather than raising — which is exactly why it needed + # finding by inspection. One substring scan per line, and only a line + # that actually contains one pays for the rewrite below. A payload whose + # own text happens to contain "\ud" trips this too and is merely + # re-encoded to the same bytes, so a false positive costs nothing. + if _SURROGATE_ESCAPE not in encoded: + return encoded + + try: + return json.dumps(_sanitize(entry, frozenset()), default=str, allow_nan=False) + except Exception: + # Nothing left to try. Losing this event is the correct outcome; losing + # the batch around it is not. + logger.exception( + "Failproof AI could not serialize an event (type=%r); dropping it", + entry.get("type") if isinstance(entry, dict) else None, + ) + return None + + +def _flush_all_at_exit() -> None: + """Final flush for every live writer. + + Registered once, at module scope, rather than per instance: `atexit` holds a + strong reference to whatever it is given, so `atexit.register(self._flush)` + made every EventWriter immortal. + + Exceptions are swallowed here on purpose. An uncaught one at this point + prints `Exception ignored in atexit callback` plus a full traceback into the + host agent's stderr, during interpreter shutdown, where it reads as a crash + in the application rather than a telemetry flush that failed. + + `_flush` takes `_flush_lock`, so this BLOCKS on any batch the flush thread + is part-way through rather than racing it. That matters more than it looks: + a batch is drained from the queue before it is written, so a flush thread + stopped mid-write — which is what happens to a daemon thread once the + interpreter starts finalizing — takes those events with it and leaves a + stray `.tmp` behind. atexit callbacks run BEFORE threads are hung, so + waiting here is enough for the in-flight write to finish normally. + """ + for ref in list(_live_writers): + writer = ref() + if writer is None: + continue + try: + writer._flush() + except Exception: + logger.exception("Failproof AI final flush failed; buffered events were lost") + + +def _reinit_all_after_fork() -> None: + """Make every inherited writer usable again in a freshly-forked child. + + Threads do not survive `fork()`. Without this the child inherits a queue, + an atexit hook and no thread to drain either: `submit()` keeps accepting, + nothing is ever published, and the events appear only if the child happens + to exit through a normal interpreter shutdown. A prefork worker (gunicorn, + celery, multiprocessing's default start method on Linux) never does — it is + killed — so telemetry from the workers, which is all of the telemetry, + silently never arrives. + """ + survivors = [] + for ref in _live_writers: + writer = ref() + if writer is None: + continue + survivors.append(ref) + try: + writer._reinit_after_fork() + except Exception: # pragma: no cover - defensive + logger.exception("Failproof AI could not restart its flush thread after fork") + _live_writers[:] = survivors + + +atexit.register(_flush_all_at_exit) + +if hasattr(os, "register_at_fork"): # pragma: no branch - absent only on Windows + os.register_at_fork(after_in_child=_reinit_all_after_fork) + + +class EventWriter: + def __init__(self, flush_interval: float = 0.5) -> None: + self._queue: collections.deque[dict] = collections.deque() + self._flush_interval = _validated_interval(flush_interval) + self._dropped = 0 + # Waited on instead of `time.sleep` so `set_flush_interval` takes effect + # on the current cycle rather than the next one. It matters at startup: + # the thread begins its first wait at import, before `configure()` has + # been called, so a caller asking for a 50 ms interval used to get one + # 500 ms cycle first — long enough for a fork or an exit to land inside + # it and take the events with it. + self._wake = threading.Event() + # Serialises `_flush`, so a batch is never being drained by two threads + # at once and — the case that actually bites — so the atexit flush waits + # for an in-flight write instead of racing interpreter shutdown against + # it. Never taken by `submit`, which must stay lock-free. + self._flush_lock = threading.Lock() + self._start_thread() + _live_writers.append(weakref.ref(self)) + + def _start_thread(self) -> None: + self._thread = threading.Thread( + target=self._flush_loop, daemon=True, name="failproofai-sdk-flush" + ) + self._thread.start() + + def _reinit_after_fork(self) -> None: + """Restore this writer inside the child half of a `fork()`. + + The inherited queue is DISCARDED rather than published. Those events + belong to the parent, which still holds them and will write them itself; + publishing them here too produced a genuine duplicate of every event + buffered at the moment of the fork. Ingest would most likely collapse + them — its dedup key hashes the canonical payload and these are + byte-identical — but "the server will probably clean it up" is not a + property this SDK gets to rely on. + + `self._wake` is rebuilt rather than reused: `threading.Event` is backed + by a lock, and a lock held by the flush thread at the instant of the + fork is inherited locked, by a thread that no longer exists. Setting it + would then deadlock the child. + """ + inherited = len(self._queue) + self._queue.clear() + self._wake = threading.Event() + self._flush_lock = threading.Lock() + self._start_thread() + if inherited: + logger.debug( + "Failproof AI discarded %d event(s) inherited from the parent process; " + "the parent still holds them", + inherited, + ) + + def submit(self, entry: dict) -> None: + # Bounded, and bounded without a lock: `len` and `popleft` on a deque are + # each a single atomic operation, and `submit` runs on the caller's agent + # loop where a lock is a latency risk and, on the fork path, a deadlock. + # A momentary overshoot under concurrent submits is fine; the cap is a + # backstop against unbounded growth, not an exact quota. + if len(self._queue) >= _QUEUE_CAP: + try: + self._queue.popleft() + except IndexError: # pragma: no cover - drained concurrently + pass + self._dropped += 1 + # Powers of ten, so a stuck spool says so without becoming the thing + # that fills the disk it is complaining about. + if self._dropped == 1 or self._dropped % 1000 == 0: + logger.warning( + "Failproof AI event queue is full (%d events); discarding oldest. " + "%d dropped so far — the spool is not draining.", + _QUEUE_CAP, + self._dropped, + ) + self._queue.append(entry) + + def set_flush_interval(self, interval: float) -> None: + # Validate first, assign second: a rejected value must leave the writer + # running on the interval it already had, not on a half-applied one. + self._flush_interval = _validated_interval(interval) + # Cut the current wait short so the new interval applies from now, not + # from the end of a cycle that may be an hour long. + self._wake.set() + + def flush_now(self) -> None: + """Drain and write any buffered entries immediately (for testing).""" + self._flush() + + def _flush_loop(self) -> None: + while True: + self._wake.wait(self._flush_interval) + self._wake.clear() + try: + self._flush() + except Exception: + # Recording must never die permanently because one flush hit a + # transient filesystem error. `_flush` restores the drained + # entries before re-raising, so the next interval retries them. + logger.exception( + "Failproof AI event flush failed; buffered events will be retried" + ) + + def _flush(self) -> None: + # The emptiness check is INSIDE the lock, and that is the whole point of + # having one. Outside it, a caller arriving while the flush thread had + # already drained the queue saw it empty and returned immediately — so + # the atexit flush did not wait, the interpreter finalised, and the + # thread died part-way through writing a batch it had already taken + # ownership of. The events were gone and the only trace was a stray + # `.tmp` — sometimes not even that. + with self._flush_lock: + if not self._queue: + return + entries = [] + while self._queue: + try: + entries.append(self._queue.popleft()) + except IndexError: + break + if entries: + try: + self._write_batch(entries) + except Exception: + # Preserve FIFO order when returning the drained batch to the + # front of entries submitted concurrently during the write. + for entry in reversed(entries): + self._queue.appendleft(entry) + raise + + def _write_batch(self, entries: list[dict]) -> None: + # Encode BEFORE touching the filesystem. An unencodable event is a + # permanent condition — retrying it produces the identical failure — so + # it is dropped here, while a filesystem error raises from below and the + # whole batch goes back on the queue to be retried. + lines = [] + dropped = 0 + for entry in entries: + encoded = _encode_entry(entry) + if encoded is None: + dropped += 1 + continue + lines.append(encoded) + + if dropped: + logger.error( + "Failproof AI dropped %d unserializable event(s) from a batch of %d; " + "the rest of the batch was published", + dropped, + len(entries), + ) + if not lines: + return + + events_dir = get_base_dir() / "events" + events_dir.mkdir(parents=True, exist_ok=True) + + now = datetime.now(timezone.utc) + ts_str = now.strftime("%Y-%m-%dT%H-%M-%S") + f"-{now.microsecond // 1000:03d}Z" + # See `_batch_seq` above: the timestamp orders batches for a human + # reading the directory, and the pid+counter suffix is what makes the + # name unique. `next()` on an itertools.count is atomic under CPython's + # GIL and, being a single C-level call, remains so on free-threaded + # builds — no lock, which matters because this runs on the atexit path + # where a lock held by a killed thread would hang the interpreter. + stem = f"event-{ts_str}-{os.getpid()}-{next(_batch_seq)}" + + tmp_path = events_dir / f"{stem}.tmp" + final_path = events_dir / f"{stem}.jsonl" + + content = "\n".join(lines) + "\n" + + # fsync BEFORE the rename. `os.replace` is atomic with respect to + # readers, but atomic is not durable: it orders nothing against the page + # cache, so a power loss or kernel crash can leave a correctly-named, + # zero-length or truncated `.jsonl`. The collector reads whatever is + # there, POSTs it, and then DELETES the file (`remove_file` in + # `crates/fpai-collect/src/uploader.rs`) — so the loss is permanent and + # silent, and an empty batch is accepted with a 200. + # + # This is not a hypothetical asymmetry: this repo's own Rust spool + # writer already calls `sync_all()` here for exactly this reason + # (`crates/fpai-collect/src/spool.rs`), with the same comment. The + # Python writer publishing into the same directories was the odd one out. + # Clean up the partial file on ANY failure. Each flush picks a fresh + # stem, so without this a persistent fault — a full disk, a read-only + # mount, a cross-device rename — strands one `.tmp` per flush cycle: + # roughly 170_000 files a day at the default interval, on the very disk + # that is already the problem. The watcher ignores them by extension, so + # nothing else would ever notice or collect them. + # + # The batch itself is NOT lost by this: `_flush` returns the entries to + # the queue and the next cycle rewrites them under a new name. + try: + with open(tmp_path, "wb") as handle: + handle.write(content.encode("utf-8")) + handle.flush() + os.fsync(handle.fileno()) + + os.replace(tmp_path, final_path) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise + + # And fsync the DIRECTORY, or the rename itself can be lost while the + # file's contents survive — leaving the batch on disk under its `.tmp` + # name, which the watcher ignores by design. + # + # Best-effort: opening a directory for fsync is a POSIX behaviour, and + # platforms that refuse it (Windows) still get the content fsync above, + # which is the half that prevents a truncated delivery. + try: + dir_fd = os.open(events_dir, os.O_RDONLY) + except OSError: # pragma: no cover - platform dependent + return + try: + os.fsync(dir_fd) + except OSError: # pragma: no cover - platform dependent + pass + finally: + os.close(dir_fd) diff --git a/sdk/python/failproofai_sdk/integrations/__init__.py b/sdk/python/failproofai_sdk/integrations/__init__.py new file mode 100644 index 000000000..db18f4253 --- /dev/null +++ b/sdk/python/failproofai_sdk/integrations/__init__.py @@ -0,0 +1,240 @@ +"""Framework adapters: the registry behind `failproofai_sdk.instrument()`. + + import failproofai_sdk + failproofai_sdk.instrument() # every framework already imported + failproofai_sdk.instrument("crewai") # exactly one + failproofai_sdk.uninstrument() # put everything back + +**This module is stdlib only, and imports no adapter until asked.** The registry +maps a name to a dotted module path *as a string*; `importlib.import_module` +runs on demand. That is what keeps `import failproofai_sdk` free of LangChain. + +Auto-detection reads `sys.modules`, deliberately **not** +`importlib.util.find_spec`. `find_spec` would report "installed", and acting on +that means importing a framework the user is not using — 200ms and a pile of +transitive imports charged to a library that was supposed to be invisible. If +the framework is not imported, there is nothing to instrument. + +Writing an adapter +------------------ +A module registered here must expose a module-level object named `adapter` +implementing `failproofai_sdk.integrations._core.Adapter`: + + name: str # the registry name + module: str # the framework module auto-detect looks for in sys.modules + def install(**options) -> None + def uninstall() -> None + +`install()` must save the **original attribute object** it replaces — use +`_core.Patcher`, which also does the "somebody patched on top of us" check — and +`uninstall()` must restore that saved object. Never re-import to restore: that +hands back whatever the current value happens to be, which is how two +instrumentation libraries silently un-patch each other. + +Every callback the adapter hands to the framework goes through `_core.safe`, and +every event goes through a `_core.RunTracker`. Adapters own the translation +table and nothing else. + +The four names are registered here **now**, before their modules exist, so that +adding an adapter is one new file rather than an edit to this one. +""" + +import importlib +import logging +import sys +import threading +from typing import Any + +from failproofai_sdk.integrations import _compat, _core +from failproofai_sdk.integrations._core import Adapter + +logger = logging.getLogger("failproofai_sdk.integrations") + +__all__ = ["instrument", "uninstrument", "active", "available"] + +# name -> dotted module path. A string, imported on demand. +_REGISTRY: dict[str, str] = { + "langchain": "failproofai_sdk.integrations.langchain", + "crewai": "failproofai_sdk.integrations.crewai", + "llama_index": "failproofai_sdk.integrations.llama_index", + "pydantic_ai": "failproofai_sdk.integrations.pydantic_ai", +} + +# Spellings people actually type. LangGraph is served by the LangChain adapter +# because LangGraph runs on langchain-core's callback manager. +_ALIASES: dict[str, str] = { + "langgraph": "langchain", + "langchain_core": "langchain", + "llamaindex": "llama_index", + "llama-index": "llama_index", + "pydantic-ai": "pydantic_ai", + "pydanticai": "pydantic_ai", +} + +# name -> the framework modules whose presence in sys.modules means "this +# framework is in use". Kept here rather than read off `adapter.module` so that +# detection imports nothing at all, not even our own adapter module. +_DETECT: dict[str, tuple[str, ...]] = { + "langchain": ("langchain_core", "langchain", "langgraph"), + "crewai": ("crewai",), + "llama_index": ("llama_index", "llama_index.core"), + "pydantic_ai": ("pydantic_ai",), +} + +# Guards _ACTIVE and every install/uninstall. `instrument()` is called from +# application startup, which in a web server can be several threads at once. +_LOCK = threading.Lock() +_ACTIVE: dict[str, Adapter] = {} + + +def available() -> tuple[str, ...]: + """Every registry name that can be instrumented, aliases excluded.""" + return tuple(sorted(_REGISTRY)) + + +def active() -> tuple[str, ...]: + """Names currently instrumented.""" + with _LOCK: + return tuple(sorted(_ACTIVE)) + + +def _canonical(name: str) -> str: + key = str(name).strip().lower().replace(" ", "") + key = _ALIASES.get(key, key) + if key not in _REGISTRY: + valid = ", ".join(sorted(set(_REGISTRY) | set(_ALIASES))) + raise ValueError( + f"failproofai_sdk: unknown framework {name!r}. Valid names are: {valid}. " + f"(Call failproofai_sdk.instrument() with no argument to auto-detect.)" + ) + return key + + +def _detected() -> list[str]: + """Registry names whose framework is already imported in this process.""" + found = [] + for name in _REGISTRY: + modules = _DETECT.get(name, (name,)) + if any(module in sys.modules for module in modules): + found.append(name) + return found + + +def _load(name: str) -> Adapter: + module = importlib.import_module(_REGISTRY[name]) + adapter = getattr(module, "adapter", None) + if adapter is None: + # A module that is itself the adapter is fine too; the attribute is the + # convention, not the contract. + adapter = module + for attribute in ("install", "uninstall"): + if not callable(getattr(adapter, attribute, None)): + raise TypeError( + f"failproofai_sdk: adapter {_REGISTRY[name]!r} does not implement " + f"{attribute}() — see failproofai_sdk.integrations._core.Adapter." + ) + return adapter # type: ignore[return-value] + + +def instrument(framework: str | None = None, **options: Any) -> tuple[str, ...]: + """Install the adapters. Returns the names newly instrumented. + + With no argument, instruments every framework already imported in this + process. Instrumenting something already active is a no-op that returns + `()`, so calling this from two code paths (or from a reloading dev server) + cannot double-record. + + An unknown name raises `ValueError` listing the valid ones — a typo that + silently records nothing is the worst outcome available. An adapter whose + `install()` raises is logged and skipped; the others still install, because + a broken LlamaIndex should not cost you LangGraph. `FAILPROOFAI_SDK_STRICT=1` turns + that skip back into a raise. + """ + if framework is None: + names = _detected() + if not names: + # WARNING, not debug. This fires only when somebody explicitly asked + # for instrumentation and got none — the import-order mistake of + # calling instrument() above the `import langchain` line — and the + # result is a process that records nothing at all, with the adapter + # installed and the docs followed. At debug level the message that + # names the exact fix was invisible under every default logging + # config, which made the one mistake that costs you all your + # telemetry the one mistake we said nothing about. + logger.warning( + "failproofai_sdk: instrument() found no supported framework in sys.modules, " + "so NOTHING was instrumented. Import your framework before calling " + "instrument(), or name one explicitly: %s.", + # The names this call would have accepted, rather than one + # hardcoded example. A reader who is not using CrewAI has to + # work out for themselves whether the message is a suggestion + # or a diagnosis, which is a poor use of the one line they get. + ", ".join(f"instrument({name!r})" for name in sorted(_REGISTRY)), + ) + else: + names = [_canonical(framework)] + + installed: list[str] = [] + with _LOCK: + for name in names: + if name in _ACTIVE: + continue + try: + adapter = _load(name) + adapter.install(**options) + except Exception: + if _core.strict(): + raise + logger.warning( + "failproofai_sdk: could not instrument %r; the rest of your process is " + "unaffected and other adapters still installed. Set " + "FAILPROOFAI_SDK_STRICT=1 to raise instead.", + name, + exc_info=True, + ) + continue + _ACTIVE[name] = adapter + installed.append(name) + return tuple(installed) + + +def uninstrument(framework: str | None = None) -> tuple[str, ...]: + """Reverse `instrument()`. Returns the names removed. Never raises. + + With no argument, removes everything. An unknown name, or a name that was + never instrumented, is a no-op — teardown that can fail is teardown people + stop calling. + """ + if framework is None: + with _LOCK: + names = list(_ACTIVE) + else: + try: + names = [_canonical(framework)] + except ValueError as exc: + logger.warning("%s", exc) + return () + + removed: list[str] = [] + with _LOCK: + for name in names: + adapter = _ACTIVE.pop(name, None) + if adapter is None: + continue + try: + adapter.uninstall() + except Exception: + logger.warning( + "failproofai_sdk: %r did not uninstall cleanly; it is no longer " + "registered, but some patches may remain.", + name, + exc_info=True, + ) + removed.append(name) + if not _ACTIVE: + # Nothing is instrumented any more, so a later instrument() starts + # from a clean slate rather than inheriting a degraded call site or + # a warning that has "already been shown". + _core.reset_failures() + _compat.reset_warnings() + return tuple(removed) diff --git a/sdk/python/failproofai_sdk/integrations/_compat.py b/sdk/python/failproofai_sdk/integrations/_compat.py new file mode 100644 index 000000000..a9b82a197 --- /dev/null +++ b/sdk/python/failproofai_sdk/integrations/_compat.py @@ -0,0 +1,247 @@ +"""Version and capability probes for the framework adapters. + +Extras (`failproofai_sdk[langchain]`) express a *floor*, not enforcement: most users +already have the framework and will never install an extra. So the real check +happens here, at `instrument()` time, in three tiers: + +1. **framework not importable** -> a hard `ImportError` whose message contains + the literal install command. Instrumenting is an explicit user action, so + silently doing nothing is never the right answer. +2. **importable but outside the declared range** -> `FailproofAICompatWarning`, + once, then best-effort. A ceiling exists because without one a clean build a + year from now pulls the next major, the callback API shifts, and the adapter + stops receiving events *while raising nothing*. +3. **a capability probe fails** -> warn and no-op **that hook only**, never the + whole adapter. + +`FAILPROOFAI_SDK_STRICT_INTEGRATIONS=1` promotes every warning here to an exception. +Warn-by-default is only defensible because there is a supported way to make it +fail loudly. + +Why the version comparison is naive +----------------------------------- +`import failproofai_sdk` is contractually zero-dependency, so this cannot use +`packaging`. `parse_version` therefore reads the **leading numeric components +only** and stops at the first component that is not purely numeric: + + "1.5.2" -> (1, 5, 2) + "2.0.0b1" -> (2, 0, 0) # pre-release suffix ignored + "0.14.23.post1" -> (0, 14, 23) # local/post segments ignored + "1.2.dev0" -> (1, 2) + +That means `2.0.0b1` compares **equal** to `2.0.0`, so a pre-release of a major +we have declared a ceiling against will not be flagged. That is deliberate: +the alternative is shipping a PEP 440 parser, and being wrong about a release +candidate is much cheaper than a runtime dependency. + +Versions are always read with `importlib.metadata.version(dist)` and **never** +`module.__version__` — that attribute is not guaranteed to exist and several of +the frameworks we target do not define it. +""" + +import importlib +import importlib.metadata +import logging +import os +import threading +import warnings +from types import ModuleType +from typing import Any, Callable + +logger = logging.getLogger("failproofai_sdk.integrations") + +__all__ = [ + "FailproofAICompatWarning", + "parse_version", + "version_string", + "version_tuple", + "require_module", + "check_version", + "probe", + "warn", + "strict_integrations", + "set_strict_integrations", + "reset_warnings", +] + + +class FailproofAICompatWarning(UserWarning): + """A framework is outside the range this adapter was written against.""" + + +_TRUTHY = frozenset({"1", "true", "yes", "on"}) + + +def _env_flag(name: str) -> bool: + """Read a boolean env var. Shared with `_core` so both flags parse alike.""" + return os.environ.get(name, "").strip().lower() in _TRUTHY + + +# Cached, because it is read on every warning and every `safe()` failure, and +# resettable, because a test that cannot flip the switch cannot test the policy. +# Same shape as `failproofai_sdk._environment`: a module global that overrides the env +# var, with None meaning "not decided yet, go look". +_strict_integrations: bool | None = None + + +def strict_integrations() -> bool: + global _strict_integrations + if _strict_integrations is None: + _strict_integrations = _env_flag("FAILPROOFAI_SDK_STRICT_INTEGRATIONS") + return _strict_integrations + + +def set_strict_integrations(value: bool | None) -> None: + """Override the flag. `None` re-reads `FAILPROOFAI_SDK_STRICT_INTEGRATIONS`.""" + global _strict_integrations + _strict_integrations = value + + +_warned: set[str] = set() +_warn_lock = threading.Lock() + + +def reset_warnings() -> None: + """Forget which warnings have already fired (tests; also `uninstrument()`).""" + with _warn_lock: + _warned.clear() + + +def warn(message: str, *, key: str | None = None) -> None: + """Warn once per `key`, or raise if strict. + + Deduplicated because these fire from `install()` *and* from hot callbacks: + a per-call warning on a chatty framework is its own outage. + """ + if strict_integrations(): + raise FailproofAICompatWarning(message) + dedup = key or message + with _warn_lock: + if dedup in _warned: + return + _warned.add(dedup) + warnings.warn(message, FailproofAICompatWarning, stacklevel=3) + logger.warning("%s", message) + + +def parse_version(text: str) -> tuple[int, ...]: + """Leading numeric components of a version string. See the module docstring.""" + parts: list[int] = [] + for chunk in str(text).split("."): + digits = "" + for ch in chunk: + if not ch.isdigit(): + break + digits += ch + if not digits: + break + parts.append(int(digits)) + if len(digits) != len(chunk): + # A partially numeric component ("0b1", "dev0", "post1") ends the + # numeric prefix — everything after it is a pre/post/local segment. + break + return tuple(parts) + + +def version_string(dist: str) -> str | None: + """The installed version of a distribution, or None if it is not installed. + + `dist` is the *distribution* name (`langchain-core`), which is frequently + not the module name (`langchain_core`). + """ + try: + return importlib.metadata.version(dist) + except importlib.metadata.PackageNotFoundError: + return None + except Exception: # pragma: no cover - a broken METADATA must not break us + logger.debug("failproofai_sdk: could not read version of %r", dist, exc_info=True) + return None + + +def version_tuple(dist: str) -> tuple[int, ...] | None: + text = version_string(dist) + return parse_version(text) if text else None + + +def require_module(module: str, *, dist: str, extra: str) -> ModuleType: + """Import a framework module or raise with the literal install command. + + Tier 1. `instrument("langchain")` on a machine without LangChain is a + mistake the user can fix in one command, so we hand them the command. + """ + try: + return importlib.import_module(module) + except ImportError as exc: + raise ImportError( + f"failproofai_sdk: cannot instrument {extra!r} because {module!r} is not importable. " + f"Install it with: pip install 'failproofai_sdk[{extra}]' " + f"(or install {dist} directly)." + ) from exc + + +def check_version( + framework: str, + dist: str, + *, + minimum: str | None = None, + below: str | None = None, + reason: str | None = None, +) -> bool: + """Tier 2. True when `dist` is inside [minimum, below); warns once if not. + + Returns True (best effort) for an unknown version too — a framework + installed from a git checkout has no usable metadata, and refusing to + instrument it would be a worse answer than trying. + """ + found = version_string(dist) + if found is None: + return True + got = parse_version(found) + if not got: + return True + + if minimum is not None and got < parse_version(minimum): + warn( + f"failproofai_sdk: {dist} {found} is older than the {minimum} this {framework} " + f"adapter was written against" + + (f" ({reason})" if reason else "") + + ". Instrumenting anyway; some events may be missing.", + key=f"{framework}:{dist}:min", + ) + return False + if below is not None and got >= parse_version(below): + warn( + f"failproofai_sdk: {dist} {found} is newer than the <{below} this {framework} " + f"adapter was written against. Instrumenting anyway, but a callback API " + f"change would make it stop recording silently — please report this.", + key=f"{framework}:{dist}:max", + ) + return False + return True + + +def probe(framework: str, hook: str, check: Callable[[], Any]) -> bool: + """Tier 3. Run a capability probe; on failure warn and disable ONE hook. + + if probe("langchain", "on_interrupt", lambda: langgraph.callbacks.GraphCallbackHandler): + ...wire it up... + + A missing capability is never a reason to abandon the whole adapter: the + other 90% of the events are still correct and still worth having. + """ + try: + ok = bool(check()) + except Exception as exc: + warn( + f"failproofai_sdk: {framework} capability probe for {hook!r} failed ({exc!r}); " + f"that hook is disabled, the rest of the adapter is unaffected.", + key=f"{framework}:{hook}", + ) + return False + if not ok: + warn( + f"failproofai_sdk: {framework} does not provide {hook!r} in this version; " + f"that hook is disabled, the rest of the adapter is unaffected.", + key=f"{framework}:{hook}", + ) + return ok diff --git a/sdk/python/failproofai_sdk/integrations/_core.py b/sdk/python/failproofai_sdk/integrations/_core.py new file mode 100644 index 000000000..7754915fd --- /dev/null +++ b/sdk/python/failproofai_sdk/integrations/_core.py @@ -0,0 +1,956 @@ +"""The parts every framework adapter shares: failure policy, patching, identity. + +An adapter under `failproofai_sdk/integrations/` is supposed to be a **translation +table** and nothing else. Everything that is genuinely hard — never raising into +the customer's call stack, restoring exactly what we replaced, mapping a +framework's run ids onto Failproof AI identity, keeping payloads inside the store's +patience — lives here, in one copy. If an adapter needs something added to this +module, that is a signal the core is wrong, not that the adapter is special. + +Three things in here are load-bearing and easy to "fix" into a bug: + +* `safe()` catches `Exception`, **never `BaseException`** — see the comment on + it before you change that. +* `RunTracker` never touches contextvars. `ContextVar.reset(token)` raises + across asyncio tasks as well as threads, so a callback surface whose start and + end are separate calls can never hold a token between them. +* `fw_fields()` is a safety rule, not a style rule. `_schema._build()` merges + extra fields **last**, so an extra named `tool_name` silently overwrites the + declared one and changes the promoted column. +""" + +import dataclasses +import functools +import logging +import re +import threading +import uuid +from collections.abc import Mapping, Sequence +from collections.abc import Set as AbcSet +from typing import Any, Callable, Protocol + +from failproofai_sdk import _context, _runtime, _schema +from failproofai_sdk._context import DEFAULT_AGENT_ID, Identity +from failproofai_sdk._events import _RESERVED +from failproofai_sdk._version import __version__ +from failproofai_sdk.integrations import _compat + +logger = logging.getLogger("failproofai_sdk.integrations") + +__all__ = [ + "Adapter", + "RunTracker", + "Patcher", + "safe", + "call_safely", + "wrap_callable", + "is_wrapped", + "unwrap", + "strict", + "set_strict", + "reset_failures", + "truncate", + "payload", + "fw_fields", + "guard_extras", + "framework_fields", + "normalize_agent_id", + "ms", + "FIELD_LIMIT", + "EVENT_BUDGET", + "FORBIDDEN_EXTRAS", +] + + +# --------------------------------------------------------------------------- +# The adapter protocol +# --------------------------------------------------------------------------- + +class Adapter(Protocol): + """What `failproofai_sdk/integrations/<framework>.py` must expose as `adapter`. + + `module` is the framework module that must already be in `sys.modules` for + auto-detection to pick this adapter up; the registry keeps its own copy of + that mapping so detection never has to import anything. + + `install()` must save the **original attribute object** it replaces (use + `Patcher`), and `uninstall()` must restore that saved object rather than + re-importing or reconstructing it. + """ + + name: str + module: str + + def install(self, **options: Any) -> None: + pass + + def uninstall(self) -> None: + pass + + +# --------------------------------------------------------------------------- +# Failure policy +# --------------------------------------------------------------------------- + +# Everything under `integrations/` obeys one rule: never raise into the +# customer's call stack. Observability that takes the process down with it is +# worse than no observability. FAILPROOFAI_SDK_STRICT=1 inverts that for tests and for +# debugging an adapter that has gone quiet — without it you can only ever prove +# "it didn't crash", never "it swallowed the right thing". +_strict: bool | None = None + + +def strict() -> bool: + global _strict + if _strict is None: + _strict = _compat._env_flag("FAILPROOFAI_SDK_STRICT") + return _strict + + +def set_strict(value: bool | None) -> None: + """Override the flag. `None` re-reads `FAILPROOFAI_SDK_STRICT`.""" + global _strict + _strict = value + + +# After this many failures at one call site we stop calling it. A broken adapter +# should cost one log line, not 40% of the process and a full disk. +_MAX_FAILURES = 3 + +_failures: dict[str, int] = {} +_disabled: set[str] = set() +_failure_lock = threading.Lock() + + +def reset_failures() -> None: + """Re-enable every degraded call site (tests; also `uninstrument()`).""" + with _failure_lock: + _failures.clear() + _disabled.clear() + + +def is_degraded(site: str) -> bool: + return site in _disabled + + +def _site_of(fn: Callable[..., Any]) -> str: + module = getattr(fn, "__module__", None) or "?" + qualname = getattr(fn, "__qualname__", None) or getattr(fn, "__name__", None) or repr(fn) + return f"{module}.{qualname}" + + +def call_safely(fn: Callable[..., Any], args: tuple, kwargs: dict, site: str) -> Any: + """Call `fn`, swallowing `Exception` and degrading a repeatedly failing site. + + Catches `Exception` and **not** `BaseException` on purpose. + `asyncio.CancelledError` has been a `BaseException` since Python 3.8, as is + `KeyboardInterrupt` and `SystemExit`; swallowing those would silently break + cancellation in every instrumented async application — the task gets + cancelled, our handler eats the CancelledError, and the framework carries on + running work that was supposed to stop. `GeneratorExit` is the same story + for generators. If you are here to "fix" this to `BaseException`, don't. + """ + if site in _disabled: + return None + try: + return fn(*args, **kwargs) + except Exception: + if strict(): + raise + count = 0 + newly_disabled = False + with _failure_lock: + count = _failures.get(site, 0) + 1 + _failures[site] = count + if count >= _MAX_FAILURES and site not in _disabled: + _disabled.add(site) + newly_disabled = True + if count == 1: + # Logged once per site, with the traceback. Repeats are silent: + # a hook that fails on every token of a streaming response would + # otherwise become the log volume. + logger.warning( + "failproofai_sdk: instrumentation hook %s failed; the instrumented call was " + "not affected. Set FAILPROOFAI_SDK_STRICT=1 to re-raise.", + site, + exc_info=True, + ) + else: + logger.debug("failproofai_sdk: instrumentation hook %s failed again (%d)", site, count) + if newly_disabled: + logger.error( + "failproofai_sdk: instrumentation hook %s failed %d times and is now disabled " + "for the rest of this process. Events from it will be missing.", + site, + count, + ) + return None + + +def safe(fn: Callable[..., Any]) -> Callable[..., Any]: + """Decorator form of `call_safely`. Put it on every callback an adapter exposes.""" + site = _site_of(fn) + + @functools.wraps(fn) + def _failproofai_safe(*args: Any, **kwargs: Any) -> Any: + return call_safely(fn, args, kwargs, site) + + _failproofai_safe.__failproofai_safe__ = True # type: ignore[attr-defined] + return _failproofai_safe + + +def _safe_call(fn: Callable[..., Any] | None, *args: Any, **kwargs: Any) -> Any: + if fn is None: + return None + return call_safely(fn, args, kwargs, _site_of(fn)) + + +# --------------------------------------------------------------------------- +# Shape A — wrapper surfaces +# --------------------------------------------------------------------------- + +def wrap_callable( + original: Callable[..., Any], + *, + before: Callable[..., Any] | None = None, + after: Callable[..., Any] | None = None, + on_error: Callable[..., Any] | None = None, +) -> Callable[..., Any]: + """Wrap a framework callable so start and end are one frame. + + * `before(*args, **kwargs)` -> an opaque ctx handed back to the others; + * `after(ctx, result)`; + * `on_error(ctx, exc)` — then the exception is re-raised, always. + + The structural guarantee, which is the whole reason this is a function and + not hand-written try/except in five adapters: **the user's call sits in + exactly one `try`, whose only job is to re-raise.** Nothing we do can change + what the wrapped callable returns or raises, because every one of our own + calls is outside that block and inside `call_safely`. That is auditable in + nine lines, and there is a test asserting the exception comes back out with + its `is` identity intact even when all three hooks raise. + """ + + @functools.wraps(original) + def _failproofai_wrapper(*args: Any, **kwargs: Any) -> Any: + ctx = _safe_call(before, *args, **kwargs) + try: + result = original(*args, **kwargs) + except BaseException as exc: # noqa: BLE001 - re-raised unconditionally + _safe_call(on_error, ctx, exc) + raise + _safe_call(after, ctx, result) + return result + + _failproofai_wrapper.__failproofai_wrapped__ = original # type: ignore[attr-defined] + return _failproofai_wrapper + + +def is_wrapped(obj: Any) -> bool: + return hasattr(obj, "__failproofai_wrapped__") + + +def unwrap(obj: Any) -> Any: + """The object we replaced, or `obj` itself if we never wrapped it.""" + return getattr(obj, "__failproofai_wrapped__", obj) + + +# --------------------------------------------------------------------------- +# Install / uninstall discipline +# --------------------------------------------------------------------------- + +class Patcher: + """Records what an `install()` replaced so `uninstall()` can put it back. + + Two rules, both of which exist because instrumentation libraries are + routinely installed alongside each other: + + 1. **Restore the saved object, never a re-import.** Re-importing to restore + hands back whatever the *current* value of the attribute's source is, + which is how two instrumentation libraries silently un-patch each other. + 2. **If the attribute is no longer ours, leave it alone.** Somebody patched + on top of us; restoring would delete their patch. We log at WARNING and + keep our record, so the customer can see it happened. + """ + + __slots__ = ("_records", "_lock") + + def __init__(self) -> None: + self._records: list[tuple[Any, str, Any, Any, bool]] = [] + self._lock = threading.Lock() + + def patch(self, obj: Any, attr: str, new: Any) -> None: + """Set `obj.attr = new`, remembering the exact object replaced.""" + existed = hasattr(obj, attr) + original = getattr(obj, attr, None) + try: + new.__failproofai_wrapped__ = original + except (AttributeError, TypeError): + # builtins, slots, C functions — the marker is best-effort, the + # identity check below falls back to `is` against what we stored. + pass + setattr(obj, attr, new) + with self._lock: + self._records.append((obj, attr, original, new, existed)) + + def restore_all(self) -> None: + """Undo every patch, newest first. Never raises.""" + with self._lock: + records = list(reversed(self._records)) + self._records.clear() + for obj, attr, original, installed, existed in records: + try: + current = getattr(obj, attr, None) + if current is not installed: + logger.warning( + "failproofai_sdk: not restoring %s.%s — it is no longer the object " + "failproofai_sdk installed (something else patched on top). Leaving " + "the current value in place rather than deleting their patch.", + getattr(obj, "__name__", type(obj).__name__), + attr, + ) + continue + if existed: + setattr(obj, attr, original) + else: + delattr(obj, attr) + except Exception: + logger.warning( + "failproofai_sdk: failed to restore %s.%s", obj, attr, exc_info=True + ) + + def __len__(self) -> int: + return len(self._records) + + +# --------------------------------------------------------------------------- +# Payload discipline +# --------------------------------------------------------------------------- + +TRUNCATION_MARKER = "…[truncated]" +FIELD_LIMIT = 8192 +EVENT_BUDGET = 32 * 1024 +_MAX_ITEMS = 100 +_MAX_DEPTH = 6 + + +class _Cut: + """Mutable 'did we cut anything' flag, threaded through the recursion.""" + + __slots__ = ("hit",) + + def __init__(self) -> None: + self.hit = False + + +def truncate(value: Any, limit: int = FIELD_LIMIT) -> Any: + """Shrink a payload value to something a column store will tolerate. + + Framework payloads are prompts, retrieved documents and tool outputs — the + three largest strings in the process. None of these are promoted columns, so + querying them means `JSONExtract` over the payload, which has already caused + a production memory blowup in the events store here. Payload discipline is not optional. + """ + return _truncate(value, limit, _Cut(), 0) + + +def _truncate(value: Any, limit: int, cut: _Cut, depth: int) -> Any: + if value is None or isinstance(value, (bool, int, float)): + return value + if isinstance(value, str): + if len(value) <= limit: + return value + cut.hit = True + return value[: max(limit - len(TRUNCATION_MARKER), 0)] + TRUNCATION_MARKER + if isinstance(value, bytes): + return _truncate(value.decode("utf-8", "replace"), limit, cut, depth) + if depth >= _MAX_DEPTH: + cut.hit = True + return _truncate(repr(value), limit, cut, _MAX_DEPTH) + # `Mapping`/`Sequence`, not `dict`/`list`. The concrete types missed every + # mapping a framework actually hands us that is not literally a dict — + # `MappingProxyType` (what `model_json_schema()` and any frozen config + # returns), `ChainMap`, and every third-party mapping — and those fell + # through to the repr branch at the bottom. A tool's JSON schema then + # reached the events store as the STRING + # `"mappingproxy({'title': 'From Unit', 'type': 'string'})"`: valid JSON + # holding a Python repr, so `JSONExtract` over it returns nothing and the + # field is unqueryable rather than merely ugly. Verified in a real stored + # row — a crewai `model_request.tools[0]…properties.from_unit`. + if isinstance(value, Mapping): + out = {} + for i, (k, v) in enumerate(value.items()): + if i >= _MAX_ITEMS: + cut.hit = True + out["…"] = f"[{len(value) - _MAX_ITEMS} more keys truncated]" + break + out[str(k)] = _truncate(v, limit, cut, depth + 1) + return out + # `str`/`bytes` are Sequences too and are handled above, so they cannot + # reach here; `Set` is a separate ABC and is not a `Sequence`. + if isinstance(value, (Sequence, AbcSet)): + items = list(value) + out_list = [_truncate(v, limit, cut, depth + 1) for v in items[:_MAX_ITEMS]] + if len(items) > _MAX_ITEMS: + cut.hit = True + out_list.append(f"[{len(items) - _MAX_ITEMS} more items truncated]") + return out_list + # A dataclass or a pydantic model is DATA, and every framework hands us + # them: a tool's argument model, its structured return, a settings object on + # a model request. They have no JSON shape by the checks above, so they were + # rendered — `Weather(city='Faro', celsius=21)` — which is a Python repr + # sitting inside a JSON string, unqueryable by `JSONExtract` and unfilterable + # in the dashboard. Each adapter was starting to unwrap them itself; doing it + # once here means an adapter that has not thought about it still records + # something readable. + shaped = _as_mapping(value) + if shaped is not None: + # Same depth, not depth + 1: the object is REPLACED by its mapping + # rather than nested inside one, and the Mapping branch above does the + # descending (and the per-field limits) from here. + return _truncate(shaped, limit, cut, depth) + + # An object with no JSON shape is rendered, not cut — `fw_truncated` means + # "data was lost", and a repr that fits has lost nothing a JSON encoder + # would have kept. + return _truncate(repr(value), limit, cut, _MAX_DEPTH) + + +def _as_mapping(value: Any) -> "dict | None": + """A dataclass instance or pydantic model as a plain dict, or None. + + Shallow on purpose. `dataclasses.asdict` and `model_dump` both recurse and + both COPY, so on a large object they duplicate the whole tree before + `_truncate` gets to decide it only wanted the first 8 KB. Reading the top + level and handing it back lets the existing walk apply the field limit, the + item cap and the depth cap on the way down, as it does for a dict. + + Everything here can execute the caller's own code — a pydantic validator, a + property behind `getattr` — so all of it is guarded, and a failure falls + through to `repr`, which is what happened before this existed. + """ + if isinstance(value, type): # the CLASS, not an instance of it + return None + if dataclasses.is_dataclass(value): + try: + return {f.name: getattr(value, f.name) for f in dataclasses.fields(value)} + except Exception: + return None + # `model_dump` and not `dict`: pydantic v2 names it distinctively, whereas + # half the objects in a typical process have some attribute called `dict` + # and calling it would be a coin flip. + dump = getattr(value, "model_dump", None) + if callable(dump): + try: + dumped = dump() + except Exception: + return None + return dumped if isinstance(dumped, Mapping) else None + return None + + +def _size(value: Any, _depth: int = 0) -> int: + if value is None or isinstance(value, (bool, int, float)): + return 8 + if isinstance(value, str): + return len(value) + if _depth >= _MAX_DEPTH: + return len(repr(value)) + # Same ABCs as `_truncate`, for the same reason: a size computed off `repr` + # for a value that `_truncate` will expand into JSON budgets the wrong + # number, and the budget is what decides which fields survive. + if isinstance(value, Mapping): + return sum(len(str(k)) + _size(v, _depth + 1) for k, v in value.items()) + if isinstance(value, (Sequence, AbcSet)): + return sum(_size(v, _depth + 1) for v in value) + return len(repr(value)) + + +def payload(fields: dict, *, limit: int = FIELD_LIMIT, budget: int = EVENT_BUDGET) -> dict: + """Apply the per-field limit and the per-event budget to a dict of extras. + + Anything cut sets `fw_truncated=True`, so a surprising-looking payload in + the dashboard is self-explaining rather than a mystery. + """ + out: dict[str, Any] = {} + remaining = budget + cut = _Cut() + for key, value in fields.items(): + shrunk = _truncate(value, limit, cut, 0) + if remaining <= 0: + cut.hit = True + continue + size = _size(shrunk) + if size > remaining: + cut.hit = True + shrunk = _truncate(shrunk, remaining, cut, 0) + size = _size(shrunk) + remaining -= size + out[key] = shrunk + if cut.hit: + out["fw_truncated"] = True + return out + + +# Every field name declared on any event dataclass, plus the five names +# `_events._RESERVED` blocks. Derived rather than hand-listed so that adding a +# field to `_schema.py` cannot leave a stale copy here. +def _declared_field_names() -> frozenset[str]: + names: set[str] = set(_RESERVED) + for obj in vars(_schema).values(): + if dataclasses.is_dataclass(obj) and isinstance(obj, type): + names.update(f.name for f in dataclasses.fields(obj)) + names.discard("extra_fields") + return frozenset(names) + + +# Deliberate exceptions: these are top-level by design. `duration_ms` is how an +# adapter reports a model call's real latency (it is not a declared parameter of +# `model_response`, and `durationOf` prefers it); `usage` is read by both the +# server summary and the dashboard as a token fallback; `request_id` pairs +# model events; `framework*` label every event. +ALLOWED_TOP_LEVEL = frozenset( + { + "request_id", + "duration_ms", + "usage", + "traceback", + "framework", + "framework_version", + "integration_version", + } +) + +# An extra whose name collides with a declared field SILENTLY OVERWRITES it: +# `_schema._build()` ends with `result.update(extra)`. An adapter reflecting a +# framework's kwargs into extras would then change `tool_name`, `model`, +# `outcome` or `input_tokens` — i.e. the promoted columns and the +# server's computed summary — and every test would still pass. +FORBIDDEN_EXTRAS = _declared_field_names() - ALLOWED_TOP_LEVEL + +_FW_PREFIX = "fw_" + + +def fw_fields(**kw: Any) -> dict: + """Build the `fw_*` extra-field namespace. + + fw_fields(run_id=run_id, node="retrieve", tags=None) + -> {"fw_run_id": "...", "fw_node": "retrieve"} + + Keys are prefixed unless they already are, or are one of the deliberate + top-level names. `None` values are dropped (the schema omits None optionals + anyway, and an extra explicitly set to None would still occupy a key). + Values go through `truncate`. Flat only — `payload_key_expr` on the server + is single-level, so a nested dict is not queryable. + """ + out: dict[str, Any] = {} + for key, value in kw.items(): + if value is None: + continue + if key in ALLOWED_TOP_LEVEL or key.startswith(_FW_PREFIX): + name = key + else: + name = _FW_PREFIX + key + out[name] = truncate(value) + return guard_extras(out) + + +def guard_extras(fields: dict) -> dict: + """Strip (or, in strict mode, reject) extras that would shadow a real field. + + Called on every emit, so even an adapter that builds its extras by hand + cannot silently rewrite a promoted column. + """ + bad = FORBIDDEN_EXTRAS & fields.keys() + if not bad: + return fields + names = sorted(bad) + message = ( + f"failproofai_sdk: extra fields {names} would overwrite declared event fields " + f"(schema merges extras last). Namespace them as fw_* instead." + ) + if strict(): + raise ValueError(message) + logger.warning("%s Dropping them.", message) + return {k: v for k, v in fields.items() if k not in bad} + + +def framework_fields(name: str, dist: str | None = None) -> dict: + """The `framework` / `framework_version` / `integration_version` triple. + + Payload-only, so **not** server-side filterable; promoting it later is a + five-file hand-mirrored change, so it is done on demand, not speculatively. + """ + out = {"framework": name, "integration_version": __version__} + version = _compat.version_string(dist) if dist else None + if version: + out["framework_version"] = version + return out + + +_ID_SEPARATORS = re.compile(r"[\s\-_.:/]+") +_EMBEDDED_UUID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") +_HEX = frozenset("0123456789abcdefABCDEF") +_AGENT_ID_LIMIT = 64 + + +def normalize_agent_id(raw: Any, default: str = DEFAULT_AGENT_ID) -> str: + """Turn a framework's label into something safe for `agent_id`. + + `agent_id` is a `LowCardinality(String)` column and the primary facet on + every dashboard surface. A UUID in it poisons that facet permanently — + LowCardinality degrades, and the filter dropdown fills with one entry per + run. So a value that looks like an id becomes `default` and the real id goes + to `fw_agent_id` / `fw_run_id` where it belongs. + """ + if raw is None: + return default + text = " ".join(str(raw).split()) + if not text: + return default + if _looks_like_id(text): + return default + text = _strip_embedded_id(text) + if not text: + return default + return text[:_AGENT_ID_LIMIT] + + +def _looks_like_id(text: str) -> bool: + """True for UUIDs and long bare hex strings.""" + try: + uuid.UUID(text) + return True + except (ValueError, AttributeError, TypeError): + pass + bare = text.replace("-", "").replace("_", "") + return len(bare) >= 16 and all(c in _HEX for c in bare) + + +def _strip_embedded_id(text: str) -> str: + """Drop a per-run id that a readable prefix is carrying. + + `_looks_like_id` only fires on a value that is an id ALL THE WAY THROUGH, so + it caught a bare UUID and missed `agent-<uuid>`, `crew_<uuid>`, + `task-3f9a1c…` — a readable name with a per-run suffix, which is the shape + frameworks actually produce and precisely the one the docs warn against + ("a role containing a UUID, timestamp, or per-run suffix"). Those went + through untouched, one distinct value per run, into a + `LowCardinality(String)` column that is the primary facet on every dashboard + surface. That is the same poisoning the whole-string guard exists to stop, + reached by the more common route. + + Stripping rather than falling back to `default`: `agent-<uuid>` still knows + it is an agent, and collapsing every such label to `main` would throw away + the one readable thing in it. A segment is dropped only if it is a UUID or a + hex run of 16+ characters, so a name like `agent-v2` or `step-3` is + untouched — and a value with nothing left after stripping falls back, which + is what the caller wanted for a bare id anyway. + """ + # Dashed UUIDs first, and as a substring: splitting on separators would + # break `task-3f9a1c2b-...` into five segments none of which is an id on its + # own, so the most standard shape of all would survive the segment pass. + stripped = _EMBEDDED_UUID.sub(" ", text) + parts = [p for p in _ID_SEPARATORS.split(stripped) if p] + kept = [p for p in parts if not _looks_like_id(p)] + # Nothing was an id: hand back the ORIGINAL, separators and all. Rejoining + # on spaces would rewrite every `node_a_b` in the process into `node a b`, + # which is a rename of the primary facet in exchange for nothing. (The + # empty segments a leading or trailing separator produces are dropped + # before the comparison, or `node_x_` alone would look like a change.) + if stripped == text and len(kept) == len(parts): + return text + return " ".join(kept).strip() + + +def ms(delta_seconds: Any) -> int: + """Whole milliseconds, as an `int`. + + The server stores `duration_ms` as a u32 and its JSON parser drops floats + (`as_u64()` -> None), so a float silently NULLs the column: the dashboard + then shows no duration and nobody sees an error. Negative deltas (clock + adjustments, a framework handing us an end before its start) clamp to 0. + """ + seconds = getattr(delta_seconds, "total_seconds", None) + value = seconds() if callable(seconds) else float(delta_seconds) + return max(round(value * 1000), 0) + + +# --------------------------------------------------------------------------- +# Shape B — callback surfaces +# --------------------------------------------------------------------------- + +@dataclasses.dataclass(slots=True) +class _Run: + identity: Identity + parent_key: Any + + +class RunTracker: + """Maps a framework's own run ids onto Failproof AI identity. + + This is Shape B: the surface where a start and its end are **separate + callbacks**, possibly on different threads (CrewAI dispatches handlers on a + ten-worker pool) or different asyncio tasks. Such an adapter can never use + contextvars — `ContextVar.reset(token)` raises `ValueError: Token was created + in a different Context` across tasks as well as threads, so a token cannot be + held between two callbacks. Instead we keep the mapping here and pass + `session_id=` / `agent_id=` **explicitly** on every emit. + + Bounded (`max_open`, FIFO eviction) because orphaned starts are normal: a + crashed run, a stream nobody consumed, a framework that forgot an end + callback. Unbounded, that is a memory leak in a long-lived server. + """ + + __slots__ = ("name", "_max_open", "_base_fields", "_runs", "_links", "_lock", "_warned") + + def __init__( + self, + name: str, + *, + max_open: int = 10_000, + base_fields: dict | None = None, + ) -> None: + self.name = name + self._max_open = max_open + self._base_fields = dict(base_fields or {}) + self._runs: dict[Any, _Run] = {} + self._links: dict[Any, Any] = {} + # RLock: `start_agent` resolves a parent while already holding it. + self._lock = threading.RLock() + self._warned = False + + # -- identity --------------------------------------------------------- + + def identity(self, key: Any, parent_key: Any = None, *, warn: bool = True) -> Identity | None: + """Resolve a run to an Failproof AI identity, in this order: + + 1. the exact `key`; + 2. the `parent_key` chain, walked through every link we have seen — a + framework's own parent_run_id is a *better* parent chain than a + contextvar stack, because it survives task hops and thread pools; + 3. **`failproofai_sdk.current()`** — this is the whole interop story. An + adapter running inside a hand-written `with failproofai_sdk.agent("planner")` + joins that same session and gets `parent_id="planner"`, so mixing the + manual API and an adapter produces one tree, not two; + 4. otherwise the event is dropped and we log **once**. + """ + with self._lock: + if key is not None: + run = self._runs.get(key) + if run is not None: + return run.identity + walked = self._walk(parent_key) + if walked is not None: + return walked + + ambient = self._ambient(coerce_agent=True) + if ambient is not None: + return ambient + + if warn: + self._warn_unresolved(key) + return None + + @staticmethod + def _ambient(*, coerce_agent: bool) -> Identity | None: + """Step 3: the identity a hand-written scope has bound, if any. + + The one copy of this. When resolving an event we coerce a missing + agent_id to `main`, but when resolving a *parent* we must not: inside a + bare `with failproofai_sdk.session(...)` there is no open agent, and claiming + `parent_id="main"` would point at an agent that never emitted an + `agent_start` — which makes the dashboard synthesize a never-ending root + span that stays `ongoing` forever. + """ + cur = _context.current() + if cur.session_id is None: + return None + return Identity( + session_id=cur.session_id, + agent_id=cur.agent_id or (DEFAULT_AGENT_ID if coerce_agent else None), + parent_id=cur.parent_id, + depth=cur.depth, + ) + + def _walk(self, parent_key: Any) -> Identity | None: + """Caller holds the lock.""" + seen: set[Any] = set() + key = parent_key + while key is not None and key not in seen: + seen.add(key) + run = self._runs.get(key) + if run is not None: + return run.identity + key = self._links.get(key) + return None + + def _warn_unresolved(self, key: Any) -> None: + if self._warned: + return + self._warned = True + logger.warning( + "failproofai_sdk: %s could not resolve a session for run %r and is dropping its " + "events. Wrap the call in `with failproofai_sdk.session():` (or " + "`with failproofai_sdk.agent(...):`) if you want them attributed. This is logged " + "once per tracker.", + self.name, + key, + ) + + def link(self, key: Any, parent_key: Any) -> None: + """Record a run's parent without making it an agent. + + Intermediate framework runs (a LangChain chain, a CrewAI task) do not + become Failproof AI spans, but their children still need to find the agent + above them. This is what makes step 2 of `identity()` work more than one + hop up. + """ + if key is None or parent_key is None or key == parent_key: + return + with self._lock: + self._evict(self._links) + self._links[key] = parent_key + + def _evict(self, table: dict) -> None: + """Caller holds the lock. FIFO — dicts keep insertion order.""" + while len(table) >= self._max_open: + table.pop(next(iter(table)), None) + + # -- agents ----------------------------------------------------------- + + def start_agent( + self, + key: Any, + *, + agent_id: str, + parent_key: Any = None, + session_id: str | None = None, + goal: str | None = None, + **fields: Any, + ) -> Identity: + """Register a run as an agent and emit `agent_start`.""" + parent = self._resolve_parent(parent_key) + sid = session_id or (parent.session_id if parent else None) or uuid.uuid4().hex + aid = normalize_agent_id(agent_id) + identity = Identity( + session_id=sid, + agent_id=aid, + parent_id=parent.agent_id if parent else None, + depth=(parent.depth + 1) if parent else 1, + ) + with self._lock: + self._evict(self._runs) + self._runs[key] = _Run(identity=identity, parent_key=parent_key) + if parent_key is not None: + self._evict(self._links) + self._links[key] = parent_key + self._emit( + "agent_start", + identity, + goal=truncate(goal) if goal is not None else None, + parent_id=identity.parent_id, + **fields, + ) + return identity + + def end_agent( + self, + key: Any, + *, + outcome: str = "success", + summary: str | None = None, + **fields: Any, + ) -> None: + """Emit `agent_end` and forget the run. + + `outcome` is `"failed"`, never `"failure"` — the server only counts + `error|failed|timeout|rejected` as a failure. + """ + with self._lock: + run = self._runs.pop(key, None) + identity = run.identity if run is not None else self.identity(key) + if identity is None: + return + self._emit( + "agent_end", + identity, + outcome=outcome, + summary=truncate(summary) if summary is not None else None, + **fields, + ) + + def open_agents(self) -> tuple[Any, ...]: + with self._lock: + return tuple(self._runs) + + def close_open_agents(self, *, outcome: str = "cancelled") -> None: + """Close every still-open agent, newest first. + + A session that dies with an open `agent_start` renders as `ongoing` + forever, so teardown closes what it opened. + """ + for key in reversed(self.open_agents()): + self.end_agent(key, outcome=outcome) + + def reset(self) -> None: + with self._lock: + self._runs.clear() + self._links.clear() + self._warned = False + + # -- everything else -------------------------------------------------- + + def emit(self, method: str, key: Any, *, parent_key: Any = None, **fields: Any) -> None: + """Emit any `failproofai_sdk.event.*` method against a run's identity. + + tracker.emit("tool_use", run_id, parent_key=parent_run_id, + tool_name=name, tool_call_id=str(run_id)) + + Drops the event (with one warning) when nothing resolves, rather than + inventing a session id: a synthesized session splits one run into many. + """ + if parent_key is not None: + self.link(key, parent_key) + identity = self.identity(key, parent_key) + if identity is None: + return + self._emit(method, identity, **fields) + + def _emit(self, method: str, identity: Identity, **fields: Any) -> None: + call_safely(self._emit_now, (method, identity, fields), {}, f"{self.name}.{method}") + + def _emit_now(self, method: str, identity: Identity, fields: dict) -> None: + # Two kinds of keyword here, and the split is by NAME, not by meaning: + # `fw_*` (plus whatever the adapter set as base fields) are payload + # extras and go through the guard and the size budget; everything else + # is a real parameter of the `event.*` method — `tool_name`, `input`, + # `outcome` — and is passed straight through. Those still get truncated, + # because `input`/`output`/`messages`/`content` are exactly the fields a + # framework fills with a 200KB prompt. + declared: dict[str, Any] = {} + extras: dict[str, Any] = {} + for key, value in fields.items(): + if value is None: + continue + if key.startswith(_FW_PREFIX): + extras[key] = value + else: + declared[key] = truncate(value) + merged = payload(guard_extras({**self._base_fields, **extras})) + # A base field named like a real parameter would be a duplicate keyword + # (TypeError inside the customer's callback); the explicit value wins. + merged = {k: v for k, v in merged.items() if k not in declared} + emit = getattr(_runtime.event, method) + emit(session_id=identity.session_id, agent_id=identity.agent_id, **declared, **merged) + + def _resolve_parent(self, parent_key: Any) -> Identity | None: + with self._lock: + walked = self._walk(parent_key) + if walked is not None: + return walked + # Same three steps as `identity()`, minus the exact-key lookup (a run + # cannot be its own parent) and minus the warning (a root agent with no + # ambient scope is normal, not a dropped event). + return self._ambient(coerce_agent=False) diff --git a/sdk/python/failproofai_sdk/integrations/crewai.py b/sdk/python/failproofai_sdk/integrations/crewai.py new file mode 100644 index 000000000..dadd0bced --- /dev/null +++ b/sdk/python/failproofai_sdk/integrations/crewai.py @@ -0,0 +1,1669 @@ +"""CrewAI adapter — written against crewai 1.15.8 (2026-07-29), floor 1.13.0. + + import failproofai_sdk, crewai + failproofai_sdk.instrument("crewai") + crew.kickoff() + +Everything here is a translation table over `crewai.events`, with exactly one +exception (6. below). Identity, correlation, payload budgets and the never-raise +policy live in `_core`. + +Six things about CrewAI that this file is shaped by, each verified against the +installed package rather than recalled: + +1. **The module is `crewai.events`, not `crewai.utilities.events`.** The old + shim was removed in 1.0.0. + +2. **Handlers are keyed by EXACT type, with no MRO walk** + (`event_bus._sync_handlers.get(type(event))`), so there is no `BaseEvent` + catch-all to register. Every event class is registered individually, which + is also what makes the anti-drift test possible. + +3. **Our handlers are `async def`, and that is a correctness requirement, not a + style choice.** `CrewAIEventsBus.emit()` dispatches *sync* handlers onto a + ten-worker `ThreadPoolExecutor`; with more than one worker, submission order + is not execution order. Measured on this version, 500 events through a sync + handler come back **out of order every single run**, while the same 500 + through an async handler are strictly ordered — async handlers are scheduled + with `run_coroutine_threadsafe` onto one background event loop, and + `call_soon_threadsafe` is FIFO. Out-of-order handling would break two of the + four rendering invariants at once: a `tool_result` could be written before + its `tool_use`, and an `agent_start` before the root's. Keep these `async`. + They must also never `await` anything, or that ordering guarantee is gone. + +4. **`BaseEvent` already carries the span tree** — `event_id`, + `parent_event_id`, and `started_event_id` on every `*Completed`/`*Failed` + event. Verified populated on tool, LLM, agent, task and crew events here, so + nesting is *read*, never reconstructed. (A per-`(role, tool)` LIFO is kept as + a fallback for the case where the pairing stack has been unwound by an + exception — that does happen, see 5.) + +5. **CrewAI's own agent executor emits `FlowStartedEvent`/`FlowFinishedEvent` + with `flow_name="AgentExecutor"`, nested inside every single + `agent_execution_started`.** Mapping flow events to agents unconditionally — + which is what the obvious reading of the API suggests — would put a spurious + `AgentExecutor` agent inside every agent execution, doubling the span tree and + poisoning the `agent_id` facet. A flow whose parent is an agent execution is + therefore a pass-through link, not an agent. Its `flow_finished` is also + *missing* when the agent's LLM call raises, so it can never be relied on to + close anything. + +6. **Not everything CrewAI does is on the bus.** `human_input=True` on a Task + goes through `crewai.core.providers.human_input`, which calls `input()` and + emits no event of any kind — verified on 1.15.16. That one surface is + instrumented by wrapping (`_patch_task_human_input`), which is the only patch + in this file; everything else here is a subscription. + +**Two kinds of node.** `_nodes` holds more than the spans that become agents: +tool calls and hook spans are recorded as *links* because CrewAI hangs real work +underneath them — a `delegate_work_to_coworker` call parents the coworker's whole +agent execution, and a flow method parents a `Crew.kickoff()` inside it. A leaf +that is not in `_nodes` is invisible to `_parent_key`, which then falls back to +the open root; the visible symptom is a flattened tree or a second session, never +an error. + +`agent_id` is the crew name or the agent **role** — `LowCardinality(String)`, +the primary dashboard facet. CrewAI's `agent.id` is a UUID and goes to +`fw_agent_id`, never here. +""" + +import json +import logging +import threading +import uuid +from collections import OrderedDict +from datetime import datetime, timezone +from typing import Any + +from failproofai_sdk.integrations import _compat +from failproofai_sdk.integrations._core import ( + RunTracker, + framework_fields, + fw_fields, + ms, + normalize_agent_id, + safe, + truncate, +) + +logger = logging.getLogger("failproofai_sdk.integrations") + +__all__ = ["adapter", "FailproofAICrewListener"] + +NAME = "crewai" +DIST = "crewai" + +# 1.13.0 is the release where `started_event_id` and a normalized `usage` dict +# are both present; below it the span tree has to be reconstructed by hand. +MIN_VERSION = "1.13.0" +# Ceiling: without one, a clean build a year from now pulls the next major, the +# event classes shift, and this adapter stops recording while raising nothing. +BELOW_VERSION = "2" + +_INSTALL_HINT = ( + "failproofai_sdk: cannot instrument 'crewai' because 'crewai.events' is not importable. " + "Install it with: pip install 'failproofai_sdk[crewai]' (or install crewai>=1.13 directly). " + "Note that the events package moved: it is 'crewai.events', not " + "'crewai.utilities.events', which was removed in crewai 1.0.0." +) + +try: # only ever executed by `instrument("crewai")` — `import failproofai_sdk` never gets here + from crewai.events import event_types as _ct + from crewai import events as _ce + from crewai.events.base_event_listener import BaseEventListener + from crewai.events.event_bus import crewai_event_bus +except ImportError as _exc: # pragma: no cover - exercised by uninstalling crewai + raise ImportError(_INSTALL_HINT) from _exc + + +# --------------------------------------------------------------------------- +# The translation table +# --------------------------------------------------------------------------- + +# event class name -> translator method name. One entry per class, because the +# bus does no MRO walk. Ordered as the dashboard reads them: structure, then +# leaves. +TABLE: tuple[tuple[str, str], ...] = ( + # --- structure: these become agent_start / agent_end ------------------- + ("CrewKickoffStartedEvent", "on_crew_started"), + ("CrewKickoffCompletedEvent", "on_crew_completed"), + ("CrewKickoffFailedEvent", "on_crew_failed"), + ("FlowStartedEvent", "on_flow_started"), + ("FlowFinishedEvent", "on_flow_finished"), + # A flow whose method raises emits `FlowFailedEvent` and NEVER + # `FlowFinishedEvent`. Without this row the flow's `agent_start` is never + # closed and the session renders `ongoing` forever — the worst outcome + # available here, and the one every other `*Failed` row in this table exists + # to avoid. + ("FlowFailedEvent", "on_flow_failed"), + ("AgentExecutionStartedEvent", "on_agent_started"), + ("AgentExecutionCompletedEvent", "on_agent_completed"), + ("AgentExecutionErrorEvent", "on_agent_error"), + # `Agent.kickoff()` — crewai's LiteAgent surface, an agent run with no Crew + # and no Task. It emits its OWN execution events, not the AgentExecution + # ones, and it is a ROOT: `parent_event_id` is None on it. Without these + # rows such a run produces no agent span at all — every LLM and tool event + # falls through to whatever ambient scope exists (agent_id "main"), or is + # dropped entirely when there is none. Nothing raises either way. + ("LiteAgentExecutionStartedEvent", "on_lite_agent_started"), + ("LiteAgentExecutionCompletedEvent", "on_agent_completed"), + ("LiteAgentExecutionErrorEvent", "on_agent_error"), + # --- structure we deliberately do NOT turn into spans ------------------ + # A CrewAI Task is a subset of the agent execution that runs it; emitting + # both would double every row and render them as siblings. The task is + # recorded as a link so its children still find the crew above them, and + # its id/name ride along on the agent's own events as fw_task_*. + ("TaskStartedEvent", "on_task_started"), + ("TaskCompletedEvent", "on_task_finished"), + ("TaskFailedEvent", "on_task_finished"), + # --- framework machinery around the agent: hook pairs ------------------ + ("MethodExecutionStartedEvent", "on_method_started"), + ("MethodExecutionFinishedEvent", "on_method_finished"), + ("MethodExecutionFailedEvent", "on_method_failed"), + ("LLMGuardrailStartedEvent", "on_guardrail_started"), + ("LLMGuardrailCompletedEvent", "on_guardrail_completed"), + # --- human in the loop ------------------------------------------------- + # A crew blocked on a person is otherwise an unexplained gap: no event + # fires for the whole wait, so the session reads as `ongoing` and its + # active duration absorbs however long the human took. The LangChain and + # LlamaIndex adapters both map their HITL surface onto the same four + # events, and this is the crewai one — `crewai.flow.runtime` emits the + # pair around every `@human_feedback` method. + ("HumanFeedbackRequestedEvent", "on_human_requested"), + ("HumanFeedbackReceivedEvent", "on_human_received"), + # --- things the agent calls: tool pairs -------------------------------- + ("ToolUsageStartedEvent", "on_tool_started"), + ("ToolUsageFinishedEvent", "on_tool_finished"), + ("ToolUsageErrorEvent", "on_tool_error"), + ("MemoryQueryStartedEvent", "on_store_started"), + ("MemoryQueryCompletedEvent", "on_store_finished"), + ("MemoryQueryFailedEvent", "on_store_failed"), + ("MemorySaveStartedEvent", "on_store_started"), + ("MemorySaveCompletedEvent", "on_store_finished"), + ("MemorySaveFailedEvent", "on_store_failed"), + ("MemoryRetrievalStartedEvent", "on_store_started"), + ("MemoryRetrievalCompletedEvent", "on_store_finished"), + ("MemoryRetrievalFailedEvent", "on_store_failed"), + ("KnowledgeQueryStartedEvent", "on_store_started"), + ("KnowledgeQueryCompletedEvent", "on_store_finished"), + ("KnowledgeQueryFailedEvent", "on_store_failed"), + ("KnowledgeRetrievalStartedEvent", "on_store_started"), + ("KnowledgeRetrievalCompletedEvent", "on_store_finished"), + ("KnowledgeSearchQueryFailedEvent", "on_store_failed"), + # --- model calls ------------------------------------------------------- + ("LLMCallStartedEvent", "on_llm_started"), + ("LLMCallCompletedEvent", "on_llm_completed"), + ("LLMCallFailedEvent", "on_llm_failed"), + # A streaming chunk emits NOTHING. A 500-token response would otherwise be + # 500 stored rows and 500 rail rows against a five-lane cap. It is + # folded into fw_chunks / fw_ttft_ms / fw_streamed on the model_response. + ("LLMStreamChunkEvent", "on_llm_chunk"), +) + +# Memory and knowledge operations are retrieval calls the agent makes, so they +# are tools, named for the surface they hit rather than the class that fired. +STORE_TOOLS: dict[str, str] = { + "MemoryQueryStartedEvent": "memory.query", + "MemoryQueryCompletedEvent": "memory.query", + "MemoryQueryFailedEvent": "memory.query", + "MemorySaveStartedEvent": "memory.save", + "MemorySaveCompletedEvent": "memory.save", + "MemorySaveFailedEvent": "memory.save", + "MemoryRetrievalStartedEvent": "memory.retrieve", + "MemoryRetrievalCompletedEvent": "memory.retrieve", + "MemoryRetrievalFailedEvent": "memory.retrieve", + "KnowledgeQueryStartedEvent": "knowledge.query", + "KnowledgeQueryCompletedEvent": "knowledge.query", + "KnowledgeQueryFailedEvent": "knowledge.query", + "KnowledgeRetrievalStartedEvent": "knowledge.search", + "KnowledgeRetrievalCompletedEvent": "knowledge.search", + "KnowledgeSearchQueryFailedEvent": "knowledge.search", +} + +# Flow names CrewAI uses for its own internal machinery. Belt and braces on top +# of the structural check in `on_flow_started` — the structural one is the rule, +# this is the fallback if a future release re-parents the executor flow. +INTERNAL_FLOW_NAMES = frozenset({"AgentExecutor"}) + +# Node kinds. "crew"/"flow"/"agent" are real Failproof AI agents; the rest are +# links that exist so a child can find the agent above it. +_AGENT_KINDS = frozenset({"crew", "flow", "agent"}) + +_MAX_NODES = 20_000 +_MAX_LEAVES = 10_000 +_MAX_STREAMS = 1_000 + + +class _Node: + """One CrewAI span, as far as we care about it.""" + + __slots__ = ("kind", "parent", "task", "agent_id") + + def __init__(self, kind, parent, task=None, agent_id=None): + self.kind = kind + self.parent = parent + self.task = task # (task_id, task_name), inherited from the parent + self.agent_id = agent_id + + +class _Leaf: + """An emitted-but-unclosed leaf span: a tool, a model call, a hook. + + `agent_end` force-closes open *pauses* but not tools, models or humans, so a + run that dies mid-tool leaves the session `ongoing` forever. Every open leaf + is remembered here and closed when the span that owns it ends. + """ + + __slots__ = ("method", "key", "parent_key", "match", "started", "fields") + + def __init__(self, method, key, parent_key, match, started, fields): + self.method = method + self.key = key + self.parent_key = parent_key + self.match = match # for the LIFO fallback when started_event_id is absent + self.started = started + self.fields = fields + + +class _CrewAIAdapter: + """The object `failproofai_sdk.integrations` loads as `adapter`.""" + + name = NAME + module = "crewai" + + def __init__(self) -> None: + # RLock: teardown resolves parents while already holding it. + self._lock = threading.RLock() + self._tracker: RunTracker | None = None + self._listener: "FailproofAICrewListener | None" = None + # (class, attribute, original descriptor, installed function) for the + # one wrapped surface — see `_patch_task_human_input`. + self._patched: list[tuple] = [] + self._nodes: "OrderedDict[str, _Node]" = OrderedDict() + self._leaves: "OrderedDict[str, _Leaf]" = OrderedDict() + self._streams: "OrderedDict[str, list]" = OrderedDict() + # pause id -> (emit key, parent key, prompt). Bounded like the rest: + # a run that is cancelled while a human is still thinking never sends + # the matching `received`, and unbounded that is a leak in a gateway. + self._pauses: "OrderedDict[str, tuple]" = OrderedDict() + self._roots: list[str] = [] + self._session_id: str | None = None + + # -- install / uninstall ------------------------------------------------ + + def install(self, **options: Any) -> None: + """Register the listener on the module-level `crewai_event_bus`. + + `options` is whatever was passed to `failproofai_sdk.instrument()`, and the same + dict reaches every adapter, so unknown keys are ignored rather than + raising a TypeError that would take out the other frameworks. + """ + _compat.check_version( + NAME, + DIST, + minimum=MIN_VERSION, + below=BELOW_VERSION, + reason="started_event_id on *Completed events, and a normalized usage dict", + ) + self._session_id = options.get("session_id") + self._tracker = RunTracker( + NAME, + base_fields=framework_fields(NAME, DIST), + ) + # Must be kept alive in a module-level global. `BaseEventListener` holds + # no reference back from the bus other than the bound handlers, so a + # listener that goes out of scope keeps "working" only by accident. + self._listener = FailproofAICrewListener(self) + self._patch_task_human_input() + + def uninstall(self) -> None: + listener, self._listener = self._listener, None + if listener is not None: + listener.teardown() + self._restore_patches() + # A run that is still open when the customer uninstruments would render + # `ongoing` forever. Close it, then forget everything. + self._close_everything(outcome="cancelled") + with self._lock: + self._nodes.clear() + self._leaves.clear() + self._streams.clear() + self._pauses.clear() + self._roots.clear() + if self._tracker is not None: + self._tracker.reset() + self._tracker = None + + # -- bookkeeping -------------------------------------------------------- + + def _note(self, event_id, parent, kind, task=None, agent_id=None) -> None: + with self._lock: + while len(self._nodes) >= _MAX_NODES: + self._nodes.pop(next(iter(self._nodes)), None) + inherited = task + if inherited is None and parent is not None: + node = self._nodes.get(parent) + inherited = node.task if node is not None else None + self._nodes[event_id] = _Node(kind, parent, inherited, agent_id) + + def _parent_key(self, parent_event_id, *, allow_root_fallback: bool = True): + """Which run a child should hang off. + + CrewAI's `parent_event_id` comes off a contextvar scope stack. On 1.15.16 + it survives crewai's own thread pool — measured: an `async_execution` + task's events all arrive with it set — so the fallback is for the events + whose parent span is genuinely gone: one that arrives after + `_close_span` popped its parent, or after `_MAX_NODES` evicted it. + Falling back to the open root keeps those inside the session instead of + minting a second one, which is the failure that splits one run into many. + + Root-capable events (`crew_kickoff_started`, `flow_started`) pass + `allow_root_fallback=False`: for them a missing parent genuinely means + "this is the top". + """ + with self._lock: + if parent_event_id is not None and parent_event_id in self._nodes: + return parent_event_id + if not allow_root_fallback or not self._roots: + return None + roots = list(self._roots) + if len(roots) == 1: + return roots[0] + return self._root_for_current_context(roots) + + def _root_for_current_context(self, roots): + """Which open root an orphaned event belongs to, with several open. + + `_roots` is process-global, so two crews kicked off on two threads leave + two entries in it and `roots[-1]` is a coin flip — one that files one + run's events under the OTHER run's session id. That is silent + cross-session corruption, which is strictly worse than a missing row, + and it is reachable without any `async_execution`: a late + `ToolUsageFinished` whose agent span has already been closed, or an + eviction at `_MAX_NODES`, is enough to reach this fallback. + + `CrewAIEventsBus.emit` copies the EMITTING thread's contextvars onto the + handler task, so an ambient `failproofai_sdk.session()` is a real signal about + which of the open runs we are inside. Prefer the newest root that agrees + with it; only guess when there is no ambient session to check against. + """ + tracker = self._tracker + if tracker is None: + return roots[-1] + ambient = tracker.identity(None, None, warn=False) + if ambient is None: + return roots[-1] + for key in reversed(roots): + identity = tracker.identity(key, warn=False) + if identity is not None and identity.session_id == ambient.session_id: + return key + return roots[-1] + + def _task_of(self, event): + """(task_id, task_name) for an event, from the event or its ancestors.""" + task_id = getattr(event, "task_id", None) + task_name = getattr(event, "task_name", None) + if task_id or task_name: + return (task_id, task_name) + with self._lock: + node = self._nodes.get(getattr(event, "parent_event_id", None)) + return node.task if node is not None else None + + # -- leaf spans --------------------------------------------------------- + + def _open_leaf(self, method, key, parent_key, match, started, fields) -> None: + with self._lock: + while len(self._leaves) >= _MAX_LEAVES: + self._leaves.pop(next(iter(self._leaves)), None) + self._leaves[key] = _Leaf(method, key, parent_key, match, started, fields) + + def _pop_leaf(self, start_id, match): + """The open leaf this ending event closes. + + `started_event_id` is the authority — it is populated on every + `*Completed`/`*Failed` event on this version. The `match` scan is the + documented fallback for the case where the scope stack was unwound by an + exception before the ending event was emitted: last-opened wins, which is + the right answer for a nested retry. + """ + with self._lock: + if start_id is not None: + leaf = self._leaves.pop(start_id, None) + if leaf is not None: + return leaf + for key in reversed(self._leaves): + if self._leaves[key].match == match: + return self._leaves.pop(key) + return None + + def _descends_from(self, node_id, ancestor) -> bool: + seen = set() + key = node_id + while key is not None and key not in seen: + if key == ancestor: + return True + seen.add(key) + node = self._nodes.get(key) + key = node.parent if node is not None else None + return False + + def _close_span(self, node_id, *, outcome, summary=None, **fields) -> None: + """End an agent span, after closing everything still open beneath it.""" + tracker = self._tracker + if tracker is None or node_id is None: + return + with self._lock: + leaves = [ + leaf + for leaf in reversed(list(self._leaves.values())) + if self._descends_from(leaf.parent_key, node_id) + ] + for leaf in leaves: + self._leaves.pop(leaf.key, None) + descendants = [ + key + for key in reversed(tracker.open_agents()) + if key != node_id and self._descends_from(key, node_id) + ] + for leaf in leaves: + self._force_close(leaf) + for key in descendants: + tracker.end_agent(key, outcome="cancelled", **fw_fields(closed_by="teardown")) + tracker.end_agent(node_id, outcome=outcome, summary=summary, **fields) + with self._lock: + self._roots = [key for key in self._roots if key != node_id] + for key in [k for k in self._nodes if self._descends_from(k, node_id)]: + self._nodes.pop(key, None) + + def _force_close(self, leaf) -> None: + """Close a leaf whose framework never reported an end.""" + tracker = self._tracker + if tracker is None: + return + fields = dict(leaf.fields) + fields.update(fw_fields(incomplete=True, closed_by="teardown")) + if leaf.method == "hook_completed": + fields["outcome"] = "cancelled" + if leaf.method == "model_response": + # Invariant: EVERY model_response carries an int duration_ms, including + # the ones we synthesize. Without it the dashboard shows no duration + # for exactly the calls that went wrong. + fields["duration_ms"] = ms(datetime.now(timezone.utc) - leaf.started) + tracker.emit(leaf.method, leaf.key, parent_key=leaf.parent_key, **fields) + + def _close_everything(self, *, outcome: str) -> None: + for node_id in list(reversed(self._roots)): + self._close_span(node_id, outcome=outcome) + tracker = self._tracker + if tracker is None: + return + with self._lock: + leaves = list(reversed(list(self._leaves.values()))) + self._leaves.clear() + for leaf in leaves: + self._force_close(leaf) + tracker.close_open_agents(outcome=outcome) + + # -- crew --------------------------------------------------------------- + + @safe + def on_crew_started(self, source, event) -> None: + tracker = self._tracker + if tracker is None: + return + # A fresh uuid4 per kickoff, NOT crew.id: crew.id is stable across + # kickoffs, so reusing it would merge every run of the same crew into one + # never-ending session. RunTracker mints it when no parent and no + # ambient scope supply one. + parent = self._parent_key(event.parent_event_id, allow_root_fallback=False) + agent_id = normalize_agent_id(getattr(event, "crew_name", None), default="crew") + tracker.start_agent( + event.event_id, + agent_id=agent_id, + parent_key=parent, + session_id=self._session_id if parent is None else None, + goal=_first_text(getattr(event, "inputs", None)), + **fw_fields( + kind="crew", + crew_name=getattr(event, "crew_name", None), + inputs=getattr(event, "inputs", None), + event_id=event.event_id, + ), + ) + self._note(event.event_id, parent, "crew", agent_id=agent_id) + with self._lock: + if parent is None: + self._roots.append(event.event_id) + + @safe + def on_crew_completed(self, source, event) -> None: + self._close_span( + self._crew_key(event), + outcome="success", + summary=_text(getattr(event, "output", None)), + **fw_fields(total_tokens=getattr(event, "total_tokens", None)), + ) + + @safe + def on_crew_failed(self, source, event) -> None: + # The error is reported on the span that OWNS it. No standalone `error` + # event: `sessionSummary.errorCount` counts both, so emitting one here + # would double-count every failed crew. + self._close_span( + self._crew_key(event), + outcome="failed", + summary=_text(getattr(event, "error", None)), + **fw_fields(error=_text(getattr(event, "error", None))), + ) + + def _crew_key(self, event): + """The crew span this ending event closes. + + `started_event_id` is set explicitly by `Crew._finish_execution`, so it + is reliable; the root fallback exists because a session left with an open + root `agent_start` renders `ongoing` forever, which is the single worst + outcome available here. + """ + with self._lock: + node_id = getattr(event, "started_event_id", None) + if node_id is not None and node_id in self._nodes: + return node_id + roots = list(self._roots) + if not roots: + return None + # Same reasoning as `_root_for_current_context`: closing "the newest + # root" while a second crew is open on another thread ends the WRONG + # crew, which reads as one run finishing early and one hanging forever. + return roots[0] if len(roots) == 1 else self._root_for_current_context(roots) + + # -- flows -------------------------------------------------------------- + + @safe + def on_flow_started(self, source, event) -> None: + tracker = self._tracker + if tracker is None: + return + parent_id = getattr(event, "parent_event_id", None) + with self._lock: + parent_node = self._nodes.get(parent_id) if parent_id else None + internal = (parent_node is not None and parent_node.kind == "agent") or ( + getattr(event, "flow_name", None) in INTERNAL_FLOW_NAMES + ) + if internal: + # CrewAI's own agent executor. Link it so the LLM and tool events + # underneath resolve to the agent, but emit nothing: it is not a + # flow the user wrote, and its flow_finished is missing entirely + # when the agent's LLM call raises. + parent = self._parent_key(parent_id) + tracker.link(event.event_id, parent) + self._note(event.event_id, parent, "flow_internal") + return + parent = self._parent_key(parent_id, allow_root_fallback=False) + agent_id = normalize_agent_id(getattr(event, "flow_name", None), default="flow") + tracker.start_agent( + event.event_id, + agent_id=agent_id, + parent_key=parent, + session_id=self._session_id if parent is None else None, + goal=_first_text(getattr(event, "inputs", None)), + **fw_fields( + kind="flow", + flow_name=getattr(event, "flow_name", None), + inputs=getattr(event, "inputs", None), + event_id=event.event_id, + ), + ) + self._note(event.event_id, parent, "flow", agent_id=agent_id) + with self._lock: + if parent is None: + self._roots.append(event.event_id) + + @safe + def on_flow_finished(self, source, event) -> None: + node_id = self._flow_key(event) + if node_id is None: + return + self._close_span( + node_id, outcome="success", summary=_text(getattr(event, "result", None)) + ) + + @safe + def on_flow_failed(self, source, event) -> None: + node_id = self._flow_key(event) + if node_id is None: + return + error = _text(getattr(event, "error", None)) + self._close_span( + node_id, + outcome="failed", + summary=error, + **fw_fields(error=error), + ) + + def _flow_key(self, event): + """The flow span this ending event closes, or None if there is nothing to close. + + `flow_internal` is the AgentExecutor pass-through link (see 5. in the + module docstring): it opened no span, so its end just drops the node. + """ + node_id = getattr(event, "started_event_id", None) + with self._lock: + node = self._nodes.get(node_id) if node_id else None + if node is not None and node.kind == "flow_internal": + self._nodes.pop(node_id, None) + return None + if node is None: + return None + return node_id + + # -- tasks: recorded, never emitted ------------------------------------- + + @safe + def on_task_started(self, source, event) -> None: + tracker = self._tracker + if tracker is None: + return + parent = self._parent_key(event.parent_event_id) + tracker.link(event.event_id, parent) + task = (getattr(event, "task_id", None), getattr(event, "task_name", None)) + self._note(event.event_id, parent, "task", task=task) + + @safe + def on_task_finished(self, source, event) -> None: + with self._lock: + self._nodes.pop(event.started_event_id, None) + + # -- agents ------------------------------------------------------------- + + @safe + def on_agent_started(self, source, event) -> None: + tracker = self._tracker + if tracker is None: + return + agent = getattr(event, "agent", None) + parent = self._parent_key(event.parent_event_id) + # `event.agent_role` is None on this event (only the tool and LLM events + # get it filled in), so the role comes off the agent object. The UUID in + # `agent.id` must never reach agent_id. + role = getattr(event, "agent_role", None) or getattr(agent, "role", None) + agent_id = normalize_agent_id(role, default="agent") + task = self._task_of(event) or (None, None) + tracker.start_agent( + event.event_id, + agent_id=agent_id, + parent_key=parent, + goal=_text(getattr(agent, "goal", None)), + **fw_fields( + kind="agent", + agent_id=str(getattr(agent, "id", "")) or None, + agent_role=role, + task_id=task[0], + task_name=task[1], + tools=_tool_names(getattr(event, "tools", None)), + allow_delegation=getattr(agent, "allow_delegation", None), + event_id=event.event_id, + ), + ) + self._note(event.event_id, parent, "agent", task=task, agent_id=agent_id) + + @safe + def on_lite_agent_started(self, source, event) -> None: + """`Agent.kickoff()`. A root, like a crew or a user-written flow. + + The role is only in `agent_info` here — `agent_role` is None on this + event, exactly as it is on `AgentExecutionStartedEvent`, and + `agent_info["id"]` is a UUID that must not reach `agent_id`. + """ + tracker = self._tracker + if tracker is None: + return + info = getattr(event, "agent_info", None) + info = info if isinstance(info, dict) else {} + parent = self._parent_key( + getattr(event, "parent_event_id", None), allow_root_fallback=False + ) + role = getattr(event, "agent_role", None) or info.get("role") + agent_id = normalize_agent_id(role, default="agent") + tracker.start_agent( + event.event_id, + agent_id=agent_id, + parent_key=parent, + session_id=self._session_id if parent is None else None, + goal=_text(info.get("goal")), + **fw_fields( + kind="agent", + lite=True, + agent_id=str(info.get("id") or "") or None, + agent_role=role, + tools=_tool_names(getattr(event, "tools", None)), + event_id=event.event_id, + ), + ) + self._note(event.event_id, parent, "agent", agent_id=agent_id) + with self._lock: + if parent is None: + self._roots.append(event.event_id) + + @safe + def on_agent_completed(self, source, event) -> None: + self._close_span( + event.started_event_id, + outcome="success", + summary=_text(getattr(event, "output", None)), + ) + + @safe + def on_agent_error(self, source, event) -> None: + self._close_span( + event.started_event_id, + outcome="failed", + summary=_text(getattr(event, "error", None)), + **fw_fields(error=_text(getattr(event, "error", None))), + ) + + # -- flow methods and guardrails: hook pairs ---------------------------- + + @safe + def on_method_started(self, source, event) -> None: + self._hook_start( + event, + hook_name=getattr(event, "method_name", None) or "flow_method", + trigger_event="flow_method", + input=_dictify(getattr(event, "params", None)), + extra=fw_fields(flow_name=getattr(event, "flow_name", None)), + ) + + @safe + def on_method_finished(self, source, event) -> None: + self._hook_end(event, outcome="success", output=_text(getattr(event, "result", None))) + + @safe + def on_method_failed(self, source, event) -> None: + self._hook_end( + event, outcome="failed", error=_text(getattr(event, "error", None)) + ) + + # -- human in the loop -------------------------------------------------- + # + # The same four events, in the same order, as the LangChain and LlamaIndex + # adapters. Neither pair is redundant and neither is optional: + # + # human_wait -> human_input carries the prompt, the answer and the + # pendingHuman count; + # agent_pause -> agent_resume is the ONLY thing that feeds pausedMs, so + # without it the wait is billed as active + # time on the agent. + # + # PAIRING is the whole difficulty here. Verified against crewai 1.15.16: + # `crewai.flow.runtime` constructs both events with **no correlation id at + # all** — `request_id` is None on both and `started_event_id` is None on the + # received one, so there is nothing to join on. What they do share is + # `(flow_name, method_name)`, and the interaction is strictly sequential: + # the runtime emits `requested`, blocks on `input()`, then emits `received`. + # + # So the lookup is `request_id` -> `(flow_name, method_name)` -> most + # recently opened, in that order. The last fallback is sound rather than a + # guess, because a blocking console prompt cannot interleave with another. + # `request_id` is tried first anyway: the enterprise async provider in + # `crewai.flow.async_feedback` does set it, and that one CAN interleave. + + @staticmethod + def _pause_lookup_keys(event) -> tuple: + """Join keys for one HITL event, most specific first.""" + keys = [] + request_id = getattr(event, "request_id", None) + if request_id: + keys.append(("request", str(request_id))) + started = getattr(event, "started_event_id", None) + if started: + keys.append(("request", str(started))) + flow = getattr(event, "flow_name", None) + method = getattr(event, "method_name", None) + if flow or method: + keys.append(("method", str(flow), str(method))) + return tuple(keys) + + @safe + def on_human_requested(self, source, event) -> None: + tracker = self._tracker + if tracker is None: + return + # The id the SDK pairs on. `event_id` is always present and unique; + # `request_id` is usually None, so it cannot serve as this. + pause_id = str(getattr(event, "event_id", None) or uuid.uuid4().hex) + parent = self._parent_key(getattr(event, "parent_event_id", None)) + prompt = _text(getattr(event, "message", None)) + record = (pause_id, parent, prompt) + + with self._lock: + while len(self._pauses) >= _MAX_NODES: + self._pauses.pop(next(iter(self._pauses)), None) + for key in self._pause_lookup_keys(event): + self._pauses[key] = record + # Always keyed by its own id too, so a `received` that does carry an + # id finds it even when flow and method are both blank. + self._pauses[("request", pause_id)] = record + + options = [str(o) for o in (getattr(event, "emit", None) or [])] or None + extra = fw_fields( + flow_name=getattr(event, "flow_name", None), + method_name=getattr(event, "method_name", None), + output=_text(getattr(event, "output", None)), + ) + tracker.emit( + "human_wait", + pause_id, + parent_key=parent, + input_id=pause_id, + prompt=prompt, + options=options, + reason="crewai_human_feedback", + **extra, + ) + tracker.emit( + "agent_pause", + pause_id, + parent_key=parent, + pause_id=pause_id, + reason="crewai_human_feedback", + **extra, + ) + + @safe + def on_human_received(self, source, event) -> None: + tracker = self._tracker + if tracker is None: + return + feedback = _text(getattr(event, "feedback", None)) + extra = fw_fields( + flow_name=getattr(event, "flow_name", None), + method_name=getattr(event, "method_name", None), + outcome_hint=getattr(event, "outcome", None), + ) + + record = None + with self._lock: + for key in self._pause_lookup_keys(event): + record = self._pauses.pop(key, None) + if record is not None: + break + if record is None and self._pauses: + # Sequential fallback: the most recently opened pause. + _, record = self._pauses.popitem(last=True) + if record is not None: + # Drop this record's other alias keys so it cannot pair twice. + for key in [k for k, v in self._pauses.items() if v is record]: + self._pauses.pop(key, None) + + if record is None: + # Feedback for a request we never saw — a flow resumed in another + # process, or one that predates instrument(). Record the answer but + # NOT agent_resume: closing a pause that never opened subtracts a + # pausedMs interval that was never added. `input_id` falls back to + # this event's own id and is NEVER None, which is a hard TypeError + # on `human_input` rather than a quietly dropped field. + own_id = str(getattr(event, "event_id", None) or uuid.uuid4().hex) + tracker.emit( + "human_input", + own_id, + parent_key=self._parent_key(getattr(event, "parent_event_id", None)), + input_id=own_id, + response=feedback, + **fw_fields(orphaned=True), + **extra, + ) + return + + pause_id, parent, _prompt = record + # agent_resume FIRST: the dashboard closes the pause on it, and + # `duration_ms` on both closing events is measured from the matching + # opening one. + tracker.emit( + "agent_resume", + pause_id, + parent_key=parent, + pause_id=pause_id, + reason="crewai_human_feedback", + **extra, + ) + tracker.emit( + "human_input", + pause_id, + parent_key=parent, + input_id=pause_id, + response=feedback, + **extra, + ) + + # -- the OTHER human-in-the-loop surface: Task(human_input=True) --------- + # + # crewai has TWO of them and only one is on the event bus. + # `@human_feedback` on a Flow method emits the pair mapped above. + # `human_input=True` on a crew Task — the surface crewai's own Crew + # documentation teaches, and the one most people mean by "HITL in crewai" — + # runs through `crewai.core.providers.human_input.SyncHumanInputProvider`, + # which prints a rich panel, calls `input()`, and emits NOTHING. Verified + # against crewai 1.15.16: no event class in `crewai.events` fires for it. + # + # Subscribing therefore cannot reach it, so this one surface is instrumented + # by WRAPPING. It is the only patch in this adapter. Three properties keep + # that acceptable: + # + # * `_prompt_input` is the narrowest possible seam — it is exactly the + # blocking call, so the pause interval is the human's wait and nothing + # else. Wrapping `handle_feedback` instead would fold the agent's + # re-invocation LLM calls into pausedMs. + # * the call is inside one `try` whose only job is to re-raise, and both + # of our own hooks are `@safe`, so nothing here can change what the + # customer's crew returns or raises. + # * if crewai moves it, `_compat.probe` disables this pair and the rest of + # the adapter is untouched. + + _HITL_PROMPT = ( + "crewai is blocked on human feedback for this task's result " + "(an empty answer accepts it)." + ) + _HITL_REASON = "crewai_task_human_input" + + def _patch_task_human_input(self) -> None: + try: + from crewai.core.providers.human_input import SyncHumanInputProvider + except Exception: # pragma: no cover - exercised by a crewai that moved it + _compat.probe(NAME, "Task(human_input=True)", lambda: False) + return + for attr, is_async in (("_prompt_input", False), ("_prompt_input_async", True)): + original = getattr(SyncHumanInputProvider, attr, None) + if original is None: + _compat.probe(NAME, f"SyncHumanInputProvider.{attr}", lambda: False) + continue + if getattr(original, "__failproofai_wrapped__", None) is not None: + # Already ours — an install that ran without its uninstall. + # Wrapping again would emit the pause twice per prompt. + continue + wrapper = self._wrap_prompt(original, is_async=is_async) + wrapper.__failproofai_wrapped__ = original + # `staticmethod(...)`, not the bare function: `_prompt_input` is a + # staticmethod and the call site is `self._prompt_input(crew)`, so a + # plain function would bind and arrive with `self` as `crew`. + descriptor = SyncHumanInputProvider.__dict__.get(attr) + setattr(SyncHumanInputProvider, attr, staticmethod(wrapper)) + self._patched.append((SyncHumanInputProvider, attr, descriptor, wrapper)) + + def _restore_patches(self) -> None: + for klass, attr, descriptor, installed in reversed(self._patched): + try: + if getattr(klass, attr, None) is not installed: + # Somebody patched on top of us; restoring would delete + # their patch. Same rule as `_core.Patcher`. + logger.warning( + "failproofai_sdk: not restoring %s.%s — it is no longer the object " + "failproofai_sdk installed.", + klass.__name__, + attr, + ) + continue + if descriptor is not None: + setattr(klass, attr, descriptor) + else: + delattr(klass, attr) + except Exception: # pragma: no cover - teardown must never raise + logger.warning( + "failproofai_sdk: failed to restore %s.%s", klass, attr, exc_info=True + ) + self._patched.clear() + + def _wrap_prompt(self, original, *, is_async): + adapter = self + + if is_async: + async def _failproofai_prompt(*args, **kwargs): + record = adapter._human_wait_start(args[0] if args else None) + try: + answer = await original(*args, **kwargs) + except BaseException as exc: # noqa: BLE001 - re-raised below + adapter._human_wait_end(record, None, exc) + raise + adapter._human_wait_end(record, answer, None) + return answer + else: + def _failproofai_prompt(*args, **kwargs): + record = adapter._human_wait_start(args[0] if args else None) + try: + answer = original(*args, **kwargs) + except BaseException as exc: # noqa: BLE001 - re-raised below + adapter._human_wait_end(record, None, exc) + raise + adapter._human_wait_end(record, answer, None) + return answer + + _failproofai_prompt.__name__ = getattr(original, "__name__", "_prompt_input") + _failproofai_prompt.__qualname__ = "failproofai_sdk.crewai.task_human_input" + return _failproofai_prompt + + @safe + def _human_wait_start(self, crew): + tracker = self._tracker + if tracker is None: + return None + pause_id = uuid.uuid4().hex + parent = self._current_agent_key() + extra = fw_fields( + surface="task_human_input", + crew_name=getattr(crew, "name", None), + ) + tracker.emit( + "human_wait", + pause_id, + parent_key=parent, + input_id=pause_id, + prompt=self._HITL_PROMPT, + reason=self._HITL_REASON, + **extra, + ) + tracker.emit( + "agent_pause", + pause_id, + parent_key=parent, + pause_id=pause_id, + reason=self._HITL_REASON, + **extra, + ) + return (pause_id, parent) + + @safe + def _human_wait_end(self, record, answer, error): + tracker = self._tracker + if tracker is None or record is None: + return + pause_id, parent = record + extra = fw_fields(surface="task_human_input", error=_text(str(error)) if error else None) + # agent_resume FIRST, for the same reason as the flow pair above. + tracker.emit( + "agent_resume", + pause_id, + parent_key=parent, + pause_id=pause_id, + reason=self._HITL_REASON, + **extra, + ) + tracker.emit( + "human_input", + pause_id, + parent_key=parent, + input_id=pause_id, + response=_text(answer), + **extra, + ) + + def _current_agent_key(self): + """The innermost open agent span — what a blocking prompt sits inside. + + There is no event to read a parent off here, so it comes from the node + table. Newest agent wins (a console prompt cannot interleave with + another), except that with two crews open on two threads the newest is a + coin flip, so an ambient session narrows it the same way + `_root_for_current_context` does. + """ + tracker = self._tracker + with self._lock: + candidates = [key for key, node in self._nodes.items() if node.kind == "agent"] + if not candidates: + return self._parent_key(None) + if len(candidates) == 1 or tracker is None: + return candidates[-1] + ambient = tracker.identity(None, None, warn=False) + if ambient is None: + return candidates[-1] + for key in reversed(candidates): + identity = tracker.identity(key, warn=False) + if identity is not None and identity.session_id == ambient.session_id: + return key + return candidates[-1] + + @safe + def on_guardrail_started(self, source, event) -> None: + self._hook_start( + event, + hook_name=getattr(event, "guardrail_name", None) or "guardrail", + trigger_event="guardrail", + input=None, + extra=fw_fields( + guardrail_type=getattr(event, "guardrail_type", None), + retry_count=getattr(event, "retry_count", None), + ), + ) + + @safe + def on_guardrail_completed(self, source, event) -> None: + # "rejected" is in the server's failure vocabulary + # (error|failed|timeout|rejected), so a tripped guardrail paints red + # rather than reading as a successful hook that happened to say no. + passed = bool(getattr(event, "success", False)) + self._hook_end( + event, + outcome="success" if passed else "rejected", + output=_text(getattr(event, "result", None)), + error=_text(getattr(event, "error", None)), + extra=fw_fields(retry_count=getattr(event, "retry_count", None)), + ) + + def _hook_start(self, event, *, hook_name, trigger_event, input, extra) -> None: + tracker = self._tracker + if tracker is None: + return + parent = self._parent_key(event.parent_event_id) + fields = dict( + hook_name=hook_name, + hook_id=event.event_id, + trigger_event=trigger_event, + input=input, + **extra, + ) + tracker.emit("hook_triggered", event.event_id, parent_key=parent, **fields) + # A flow method is a PARENT: a `Crew.kickoff()` inside one arrives with + # `parent_event_id` set to this event. Unless the id is a node, + # `_parent_key` cannot see it, `on_crew_started` reads "no parent" and + # mints a SECOND ROOT — a whole separate session for a crew that ran + # inside the flow. See `_tool_start` for the same rule. + self._note(event.event_id, parent, "hook") + self._open_leaf( + "hook_completed", + event.event_id, + parent, + ("hook", hook_name), + event.timestamp, + {"hook_name": hook_name, "hook_id": event.event_id}, + ) + + def _hook_end(self, event, *, outcome, output=None, error=None, extra=None) -> None: + tracker = self._tracker + if tracker is None: + return + leaf = self._pop_leaf(event.started_event_id, ("hook", _hook_name_of(event))) + if leaf is None: + return + with self._lock: + self._nodes.pop(leaf.key, None) + # duration_ms is auto-computed by the SDK from the hook_triggered we + # emitted, and is hard-rejected from callers on hook_completed. + tracker.emit( + "hook_completed", + leaf.key, + parent_key=leaf.parent_key, + hook_name=leaf.fields["hook_name"], + hook_id=leaf.fields["hook_id"], + outcome=outcome, + output=output, + error=error, + **(extra or {}), + ) + + # -- tools -------------------------------------------------------------- + + @safe + def on_tool_started(self, source, event) -> None: + self._tool_start( + event, + tool_name=getattr(event, "tool_name", None) or "tool", + input=_dictify(getattr(event, "tool_args", None)), + extra=fw_fields( + tool_class=getattr(event, "tool_class", None), + run_attempts=getattr(event, "run_attempts", None), + agent_role=getattr(event, "agent_role", None), + agent_id=getattr(event, "agent_id", None), + ), + ) + + @safe + def on_tool_finished(self, source, event) -> None: + self._tool_end( + event, + output=_text(getattr(event, "output", None)), + error=None, + extra=fw_fields( + from_cache=getattr(event, "from_cache", None), + run_attempts=getattr(event, "run_attempts", None), + ), + ) + + @safe + def on_tool_error(self, source, event) -> None: + # A tool failure the agent loop catches and retries is not a run-level + # error, so it is reported on the tool_result and nowhere else. + self._tool_end( + event, + output=None, + error=_text(getattr(event, "error", None)) or "tool failed", + extra=fw_fields(run_attempts=getattr(event, "run_attempts", None)), + ) + + @safe + def on_store_started(self, source, event) -> None: + name = STORE_TOOLS.get(type(event).__name__, "memory") + self._tool_start( + event, + tool_name=name, + input=_dictify(getattr(event, "query", None) or getattr(event, "value", None)), + extra=fw_fields(limit=getattr(event, "limit", None), store=name), + ) + + @safe + def on_store_finished(self, source, event) -> None: + results = ( + getattr(event, "results", None) + or getattr(event, "memory_content", None) + or getattr(event, "retrieved_knowledge", None) + ) + self._tool_end( + event, + output=truncate(results), + error=None, + extra=fw_fields( + query_time_ms=getattr(event, "query_time_ms", None), + save_time_ms=getattr(event, "save_time_ms", None), + retrieval_time_ms=getattr(event, "retrieval_time_ms", None), + ), + ) + + @safe + def on_store_failed(self, source, event) -> None: + self._tool_end( + event, + output=None, + error=_text(getattr(event, "error", None)) or "store operation failed", + extra={}, + ) + + def _tool_start(self, event, *, tool_name, input, extra) -> None: + tracker = self._tracker + if tracker is None: + return + parent = self._parent_key(event.parent_event_id) + # tool_call_id is CrewAI's own event_id, verbatim: it is a uuid4, so it + # cannot collide inside `_pending`, and it lines our rows up with the + # framework's own logs. + tracker.emit( + "tool_use", + event.event_id, + parent_key=parent, + tool_name=tool_name, + tool_call_id=event.event_id, + input=input, + **extra, + ) + # A tool call is a PARENT, not only a leaf. `delegate_work_to_coworker` + # and `ask_question_to_coworker` run a whole coworker underneath the + # call: verified on crewai 1.15.16, the delegate's + # `AgentExecutionStartedEvent.parent_event_id` is exactly this event's id. + # Unless that id is a node, `_parent_key` misses it, falls back to the + # open root, and every delegated agent is re-parented onto the CREW — + # manager and coworker rendered as siblings, which is the whole + # hierarchical process flattened into one level. + self._note(event.event_id, parent, "tool") + self._open_leaf( + "tool_result", + event.event_id, + parent, + ("tool", getattr(event, "agent_role", None), tool_name), + event.timestamp, + {"tool_name": tool_name, "tool_call_id": event.event_id}, + ) + + def _tool_end(self, event, *, output, error, extra) -> None: + tracker = self._tracker + if tracker is None: + return + name = getattr(event, "tool_name", None) or STORE_TOOLS.get( + type(event).__name__, "tool" + ) + leaf = self._pop_leaf( + event.started_event_id, ("tool", getattr(event, "agent_role", None), name) + ) + if leaf is None: + return + with self._lock: + self._nodes.pop(leaf.key, None) + # duration_ms is hard-rejected on tool_result and is computed by the SDK + # from the tool_use we emitted a moment ago. + tracker.emit( + "tool_result", + leaf.key, + parent_key=leaf.parent_key, + tool_name=leaf.fields["tool_name"], + tool_call_id=leaf.fields["tool_call_id"], + output=output, + error=error, + **extra, + ) + + # -- model calls -------------------------------------------------------- + + @safe + def on_llm_started(self, source, event) -> None: + tracker = self._tracker + if tracker is None: + return + parent = self._parent_key(event.parent_event_id) + call_id = getattr(event, "call_id", None) or event.event_id + task = self._task_of(event) or (None, None) + tracker.emit( + "model_request", + event.event_id, + parent_key=parent, + model=getattr(event, "model", None), + messages=_messages(getattr(event, "messages", None)), + tools=_listify(getattr(event, "tools", None)), + request_id=call_id, + **fw_fields( + call_id=call_id, + temperature=getattr(event, "temperature", None), + max_tokens=getattr(event, "max_tokens", None), + stream=getattr(event, "stream", None), + task_id=task[0], + task_name=task[1], + ), + ) + self._open_leaf( + "model_response", + event.event_id, + parent, + ("llm", call_id), + event.timestamp, + {"model": getattr(event, "model", None), "request_id": call_id}, + ) + + @safe + def on_llm_completed(self, source, event) -> None: + usage = getattr(event, "usage", None) + input_tokens, output_tokens, normalized = _tokens(usage) + self._model_end( + event, + content=_text(getattr(event, "response", None)), + stop_reason=getattr(event, "finish_reason", None), + error=None, + extra=dict( + input_tokens=input_tokens, + output_tokens=output_tokens, + usage=normalized or None, + **fw_fields( + usage_raw=usage if isinstance(usage, dict) else None, + response_id=getattr(event, "response_id", None), + call_type=_enum_value(getattr(event, "call_type", None)), + ), + ), + ) + + @safe + def on_llm_failed(self, source, event) -> None: + # Reported on the model_response, which is why `error` was added to that + # event: a failed LLM call otherwise paints the row red but not the span. + self._model_end( + event, + content=None, + stop_reason="error", + error=_text(getattr(event, "error", None)) or "llm call failed", + extra={}, + ) + + def _model_end(self, event, *, content, stop_reason, error, extra) -> None: + tracker = self._tracker + if tracker is None: + return + call_id = getattr(event, "call_id", None) + leaf = self._pop_leaf(event.started_event_id, ("llm", call_id)) + if leaf is None: + return + stream = self._pop_stream(call_id) + fields = dict(extra) + if stream is not None: + chunks, first_ts = stream + fields.update( + fw_fields( + streamed=True, + chunks=chunks, + ttft_ms=ms(first_ts - leaf.started) if first_ts is not None else None, + ) + ) + # duration_ms is NOT guarded on model_response, and `durationOf` prefers + # the closing event's value over end-start — which is what keeps model + # durations honest even when the dashboard's FIFO pairing brackets the + # wrong pair. It must be an int: the server's JSON parser drops floats + # and would silently NULL the column. + tracker.emit( + "model_response", + leaf.key, + parent_key=leaf.parent_key, + model=getattr(event, "model", None) or leaf.fields["model"], + request_id=leaf.fields["request_id"], + content=content, + stop_reason=stop_reason, + error=error, + duration_ms=ms(event.timestamp - leaf.started), + **fields, + ) + + @safe + def on_llm_chunk(self, source, event) -> None: + """Emits nothing. Counts chunks and stamps time-to-first-token, once.""" + key = getattr(event, "call_id", None) or getattr(event, "parent_event_id", None) + if key is None: + return + with self._lock: + entry = self._streams.get(key) + if entry is None: + while len(self._streams) >= _MAX_STREAMS: + self._streams.pop(next(iter(self._streams)), None) + self._streams[key] = [1, event.timestamp] + else: + entry[0] += 1 + + def _pop_stream(self, call_id): + if call_id is None: + return None + with self._lock: + entry = self._streams.pop(call_id, None) + return (entry[0], entry[1]) if entry else None + + +# --------------------------------------------------------------------------- +# The listener +# --------------------------------------------------------------------------- + +def event_class(name: str): + """The event class `name`, or None. + + TWO namespaces, because crewai has two and neither is complete. + `crewai.events.event_types` is the flat re-export the bulk of the table + resolves against, but the flow events — `HumanFeedbackRequestedEvent` among + them — are NOT in it; they are reachable only through `crewai.events`, whose + module-level `__getattr__` imports them lazily from + `crewai.events.types.flow_events`. + + Resolving against `event_types` alone is not an error you would see: the + lookup returns None, `probe()` disables that one hook, and the adapter + carries on recording everything else. The HITL events were mapped and + silently never registered until this existed. `getattr`, never `dir()` — + a lazy re-export does not appear in `dir()`. + """ + return getattr(_ct, name, None) or getattr(_ce, name, None) + + +class FailproofAICrewListener(BaseEventListener): + """Registers one handler per event class on the module-level bus. + + `BaseEventListener.__init__` is what performs the registration (it calls + `setup_listeners` with the `crewai_event_bus` singleton), so constructing + the instance *is* the install step — and the instance has to be kept alive + by the adapter or it is garbage collected and silently stops working. + + Every handler is `async def` for the ordering reason in the module + docstring, and does nothing but call a `safe`-wrapped translator, so a bug + in this file can never take down the customer's crew. + """ + + def __init__(self, adapter: _CrewAIAdapter) -> None: + self._adapter = adapter + self._registered: list[tuple[type, Any]] = [] + super().__init__() + + def setup_listeners(self, crewai_event_bus) -> None: + for class_name, method_name in TABLE: + klass = event_class(class_name) + if klass is None: + # Tier 3: one missing event class disables one hook, never the + # whole adapter — the other 90% of the events are still correct. + _compat.probe(NAME, class_name, lambda: False) + continue + handler = self._make_handler(getattr(self._adapter, method_name), method_name) + crewai_event_bus.register_handler(klass, handler) + self._registered.append((klass, handler)) + + @staticmethod + def _make_handler(translator, method_name): + async def _failproofai_handler(source, event): + translator(source, event) + + _failproofai_handler.__name__ = f"failproofai_{method_name}" + _failproofai_handler.__qualname__ = f"failproofai_sdk.crewai.{method_name}" + return _failproofai_handler + + def teardown(self) -> None: + """`crewai_event_bus.off()` for everything `setup_listeners` added.""" + for event_class, handler in self._registered: + try: + crewai_event_bus.off(event_class, handler) + except Exception: # pragma: no cover - teardown must never raise + logger.warning( + "failproofai_sdk: could not unregister the crewai handler for %s", + getattr(event_class, "__name__", event_class), + exc_info=True, + ) + self._registered.clear() + + def handlers(self) -> tuple[tuple[type, Any], ...]: + """(event class, handler) pairs currently registered. For tests.""" + return tuple(self._registered) + + +# --------------------------------------------------------------------------- +# Payload helpers +# --------------------------------------------------------------------------- + +def _text(value) -> "str | None": + if value is None: + return None + if isinstance(value, str): + return truncate(value) + raw = getattr(value, "raw", None) + return truncate(raw if isinstance(raw, str) else str(value)) + + +def _first_text(mapping) -> "str | None": + """A goal string for a crew or flow, from whatever inputs it was given.""" + if isinstance(mapping, dict): + for value in mapping.values(): + if isinstance(value, str) and value.strip(): + return truncate(value) + return None + + +def _dictify(value) -> "dict | None": + """`input=` is declared `dict | None`; a bare string would break the shape. + + CrewAI hands `tool_args` over as the raw JSON string the model produced, so + it is decoded when it decodes: an object in the payload is queryable with + `payload_key_expr`, a JSON-string-inside-a-string is not. + """ + if value is None: + return None + if isinstance(value, dict): + return truncate(value) + if isinstance(value, str): + stripped = value.strip() + if stripped.startswith("{"): + try: + decoded = json.loads(stripped) + except ValueError: + decoded = None + if isinstance(decoded, dict): + return truncate(decoded) + return {"input": truncate(value)} + return {"input": truncate(str(value))} + + +def _listify(value) -> "list | None": + if value is None: + return None + return truncate(list(value) if isinstance(value, (list, tuple)) else [value]) + + +def _messages(value) -> "list | None": + """`messages=` is declared `list[dict]`; CrewAI also allows a bare string.""" + if value is None: + return None + if isinstance(value, str): + return [{"role": "user", "content": truncate(value)}] + return _listify(value) + + +def _tool_names(tools) -> "list | None": + if not tools: + return None + return [getattr(tool, "name", None) or type(tool).__name__ for tool in tools] + + +def _enum_value(value): + return getattr(value, "value", value) if value is not None else None + + +def _hook_name_of(event) -> "str | None": + return ( + getattr(event, "method_name", None) + or getattr(event, "guardrail_name", None) + or "guardrail" + ) + + +# The usage dict is whatever the provider returned, so both spellings are in the +# wild and neither is guaranteed. Reading only one of them reports zero tokens +# for half the providers, at HTTP 200, forever. +_INPUT_KEYS = ("prompt_tokens", "input_tokens", "promptTokens", "inputTokens") +_OUTPUT_KEYS = ("completion_tokens", "output_tokens", "completionTokens", "outputTokens") +_TOTAL_KEYS = ("total_tokens", "totalTokens") + + +def _tokens(usage): + """(input_tokens, output_tokens, normalized_usage_dict).""" + if not isinstance(usage, dict): + return None, None, {} + input_tokens = _count(usage, _INPUT_KEYS) + output_tokens = _count(usage, _OUTPUT_KEYS) + total = _count(usage, _TOTAL_KEYS) + if total is None and (input_tokens is not None or output_tokens is not None): + total = (input_tokens or 0) + (output_tokens or 0) + normalized = {} + for key, value in ( + ("input_tokens", input_tokens), + ("output_tokens", output_tokens), + ("total_tokens", total), + ): + if value is not None: + normalized[key] = value + return input_tokens, output_tokens, normalized + + +def _count(usage, keys): + for key in keys: + value = usage.get(key) + if isinstance(value, bool): + continue + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str) and value.isdigit(): + return int(value) + return None + + +adapter = _CrewAIAdapter() diff --git a/sdk/python/failproofai_sdk/integrations/langchain.py b/sdk/python/failproofai_sdk/integrations/langchain.py new file mode 100644 index 000000000..c7ff5ee8d --- /dev/null +++ b/sdk/python/failproofai_sdk/integrations/langchain.py @@ -0,0 +1,1988 @@ +"""LangChain + LangGraph adapter. + +Written against **langchain-core 1.5.2** and **langgraph 1.2.10** (2026-07-29), +and every claim below was read out of those installed packages rather than +recalled. Where this file disagrees with the LangChain docs or with another +vendor's integration, the disagreement is deliberate and the reason is in the +comment next to it. + +How it attaches +--------------- +`langchain_core.tracers.context.register_configure_hook` is public, documented, +and survived the 0.x -> 1.x rewrite. `CallbackManager.configure` injects the +handler into **every** callback manager it builds, so no call site changes:: + + failproofai_sdk.instrument("langchain") + graph.invoke(...) # already recorded + +Four consequences of that hook shape drive the code: + +* with the ``env_var`` form a **fresh handler is constructed per callback + manager** — many times per run — so ``FailproofAITracer.__init__`` is zero-arg + and cheap and **all** cross-callback state lives in the module-level + ``_STATE``; +* ``inheritable=True`` is required or child runs never see it; +* there is **no deregister API** (``_configure_hooks`` is append-only and + private), so ``uninstall()`` clears the ContextVar and unsets the env var; +* we do **not** patch ``BaseCallbackManager.__init__``. OpenInference and + Traceloop do, and ``BaseCallbackManager.merge()`` builds a new manager with + handlers already passed in, so their ``isinstance`` dedup misses and the + handler is added twice. That is a real duplicate-event bug; MLflow patches + ``merge`` as well to work around it. The configure hook has no such hole. + +Why `BaseTracer` +---------------- +`BaseTracer` assembles the run tree and hands over `Run` objects with inputs, +outputs, metadata and timings already collected — two override points instead of +twenty hand-correlated callbacks. `langchain_core.tracers.schemas.Run` *is* +`langsmith.RunTree`. We also subclass `langgraph.callbacks.GraphCallbackHandler` +(under try/except: it is new in langgraph 1.2) for first-class interrupt/resume. + +`run_inline = True` is not optional. `AsyncCallbackManager` dispatches sync +handlers through `run_in_executor` unless a handler sets it, and that hop can +**reorder callbacks** — which scrambles timestamp order and breaks every pairing +in this file. `writer.submit()` is a `deque.append`, so inline on the event loop +is safe. `raise_error` is normally False: LangChain already firewalls handler +exceptions in `handle_event`, and so does `_core.safe`. It follows +`FAILPROOFAI_SDK_STRICT`, because that same firewall otherwise swallows the exception +`safe()` re-raises under strict and the escape hatch does nothing here. + +The mapping +----------- +=========================== ========================================== +LangChain / LangGraph Failproof AI +=========================== ========================================== +root run (no parent) ``agent_start`` / ``agent_end`` +LangGraph node ``hook_triggered`` / ``hook_completed`` +compiled subgraph nested ``agent_start`` (``root/node``) +tool run ``tool_use`` / ``tool_result`` +retriever run ``tool_use`` / ``tool_result`` (summarised) +chat model / LLM run ``model_request`` / ``model_response`` +``interrupt()`` ``human_wait`` + ``agent_pause`` +``Command(resume=...)`` ``agent_resume`` + ``human_input`` +intermediate chains *nothing* (see ``include_chains``) +=========================== ========================================== + +**A LangGraph node is a hook, not a nested agent.** `agent_id` is a +`LowCardinality(String)` column and the primary facet on every dashboard +surface, and `agent_sessions.agent_id = any(...)` returns the first `agent_id` +by time — so promoting `retrieve`, `grade_documents` and `should_continue` to +agents would both drown the facet and label the session with a random node. +Hook spans render structurally identically, and `/hooks` becomes a per-node +latency page for free. + +Corrections to received wisdom, both verified here +-------------------------------------------------- +1. **`thread_id` IS available to callbacks** on this stack. langchain-core's + `ensure_config` stopped promoting `configurable` into `metadata`, which is + what every "thread_id is None" report is about — but langgraph 1.2 re-adds it + in `langgraph._internal._config` via ``_PROPAGATE_TO_METADATA`` = + {thread_id, checkpoint_id, checkpoint_ns, task_id, run_id, assistant_id, + graph_id}. So `metadata["thread_id"]` is populated and is a good default + session key. It is still only the *fourth* resolution step, because a + `thread_id` is a conversation, not necessarily a run. +2. **`GraphCallbackHandler.on_interrupt` does NOT fire for a handler installed + through `register_configure_hook`.** `Pregel.stream` builds the lifecycle + manager with `get_sync_graph_callback_manager_for_config(config)`, which + reads the **raw** ``config["callbacks"]`` — the configure hooks never touch + it — and then gates the whole feature on + ``has_graph_lifecycle_callbacks=bool(manager.handlers)``. So we wrap that + factory (see `_install_graph_callbacks`) to attach the handler to the manager + it returns. If the wrap does not apply, the exception-path fallback below + still produces the full HITL pair; only the resume event needs the wrap, and + that has its own fallback too. + +Control flow is not failure +--------------------------- +LangGraph's runnable does ``except BaseException as e: run_manager +.on_chain_error(e); raise`` with no special case for interrupts, so **every** +HITL pause arrives as an error callback. Reporting it would paint a red error +plus ``agent_end(outcome="failed")`` on every human approval. Any +`langgraph.errors.GraphBubbleUp` subclass — `GraphInterrupt`, `NodeInterrupt`, +`ParentCommand`, `GraphDrained` — is therefore treated as control flow. +""" + +import contextvars +import dataclasses +import logging +import os +import threading +from datetime import datetime, timezone +from typing import Any, Iterable + +from failproofai_sdk import _context +from failproofai_sdk.integrations import _compat, _core +from failproofai_sdk.integrations._core import ( + Patcher, + RunTracker, + framework_fields, + fw_fields, + ms, + normalize_agent_id, + safe, + truncate, +) + +logger = logging.getLogger("failproofai_sdk.integrations") + +NAME = "langchain" +MODULE = "langchain_core" +DIST = "langchain-core" +EXTRA = "langchain" +ENV_VAR = "FAILPROOFAI_SDK_TRACE_LANGCHAIN" + +# The documented escape hatch for session stitching: +# graph.invoke(x, config={"metadata": {"failproofai_sdk_session_id": sid}}) +SESSION_METADATA_KEY = "failproofai_sdk_session_id" + +# Checked in order after the explicit key above. `thread_id` is last because a +# thread is a *conversation*; two turns on one thread are two runs, and a user +# who wants them merged has said so with one of the earlier keys. +SESSION_METADATA_FALLBACKS = ("session_id", "conversation_id", "thread_id") + +# LangSmith's convention for "machinery, not user-visible work". We demote these +# rather than dropping them: they never become a span, but they stay in the +# parent chain so their children still find the agent above them. +HIDDEN_TAG = "langsmith:hidden" + +_FIELD_LIMIT = 2048 # inputs/outputs are graph state; the per-event budget is not the only guard + + +# --------------------------------------------------------------------------- +# Framework imports +# --------------------------------------------------------------------------- +# This module is only ever imported by `instrument("langchain")`, so importing +# the framework at module scope is fine — `import failproofai_sdk` never gets here. +# `require_module` turns a missing install into an ImportError carrying the +# literal install command. +_compat.require_module(MODULE, dist=DIST, extra=EXTRA) + +from langchain_core.tracers.base import BaseTracer # noqa: E402 +from langchain_core.tracers.context import register_configure_hook # noqa: E402 + +try: # langgraph >= 1.2 only + from langgraph.callbacks import GraphCallbackHandler as _GraphCallbackHandler +except ImportError: # pragma: no cover - exercised on langgraph < 1.2 / absent + _GraphCallbackHandler = None + +try: + from langgraph.errors import GraphBubbleUp as _GraphBubbleUp +except ImportError: # pragma: no cover + _GraphBubbleUp = None + +try: # `Command` tells a resume from a fresh turn; `Interrupt` derives a pause id + from langgraph.types import Command as _Command + from langgraph.types import Interrupt as _Interrupt +except ImportError: # pragma: no cover + _Command = None + _Interrupt = None + +# Name-based fallback for the case where `langgraph.errors` moved. Getting this +# wrong is expensive and silent (a red error on every human approval), so it is +# worth a belt-and-braces check rather than a bare `isinstance`. +_CONTROL_FLOW_NAMES = frozenset( + {"GraphBubbleUp", "GraphInterrupt", "NodeInterrupt", "ParentCommand", "GraphDrained"} +) + + +def _is_control_flow(exc: BaseException | None) -> bool: + if exc is None: + return False + if _GraphBubbleUp is not None and isinstance(exc, _GraphBubbleUp): + return True + return any(cls.__name__ in _CONTROL_FLOW_NAMES for cls in type(exc).__mro__) + + +if _GraphCallbackHandler is not None: + _GraphBase: Any = _GraphCallbackHandler +else: # pragma: no cover - only on langgraph < 1.2 + + class _GraphBase: # type: ignore[no-redef] + """Stand-in so the two lifecycle overrides always have a home. + + Deliberately **not** registered anywhere: langgraph's dispatch is an + `isinstance(h, GraphCallbackHandler)` filter, so on an older langgraph + these methods are simply never called, which is the correct behaviour. + """ + + def on_interrupt(self, event: Any) -> Any: ... + + def on_resume(self, event: Any) -> Any: ... + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +@dataclasses.dataclass +class _Options: + """Everything `instrument("langchain", ...)` accepts.""" + + session_id: str | None = None + include_chains: frozenset = frozenset() + capture_content: bool = True + graph_callbacks: bool = True + + +@dataclasses.dataclass +class _Session: + """One Failproof AI session, which may outlive a single `.invoke()`. + + It has to: a human-in-the-loop graph runs `invoke()`, interrupts, and is + resumed by a *second* `invoke()` minutes later. Both are the same session + and the same root agent, and the agent stays open across the gap so that + `agent_pause` -> `agent_resume` measures the wait (which is the only thing + that feeds the dashboard's `pausedMs`). + """ + + session_id: str + agent_key: str + agent_id: str + open_pauses: dict = dataclasses.field(default_factory=dict) + reported_error: bool = False + + +@dataclasses.dataclass +class _RemoteResume: + """Bookkeeping for a resume whose pause was opened in ANOTHER process. + + Set on the ROOT `_RunInfo` only, and only when this process has no open + pause of its own to close — i.e. exactly the deployment shape where the + interrupt was served by one worker and the approval by another. See + `_close_remote_pause` for how the pause id is recovered. + """ + + value: Any = None + # checkpoint-ns tuple -> the `langgraph_step` of the first node seen at that + # level. Only that first superstep re-runs interrupted tasks; everything + # after it is ordinary downstream work. + levels: dict = dataclasses.field(default_factory=dict) + # The deepest level langgraph has told us is resuming. A subgraph host node + # sits at a shallower level than the task that actually interrupted. + deepest: tuple | None = None + done: set = dataclasses.field(default_factory=set) + + +@dataclasses.dataclass +class _RunInfo: + """What we need about a LangChain run after its start callback returns.""" + + id: str + parent: str | None + name: str + run_type: str + started: datetime = dataclasses.field(default_factory=_now) + hidden: bool = False + kind: str = "" # "" | "root" | "node" | "tool" | "retriever" | "model" | "chain" + # Set only on a ROOT run that is itself a leaf — a bare `llm.invoke()`, a + # standalone tool. Such a run is both the session's agent and a model/tool + # call, and it needs both pairs. See `_start_root`. + leaf_kind: str = "" + root: str | None = None + session: _Session | None = None + node: str | None = None + tool_call_id: str | None = None + model: str | None = None + messages: list | None = None + ttft_ms: int | None = None + chunks: int = 0 + remote: _RemoteResume | None = None # root only + + +class _State: + """All cross-callback state, module level on purpose. + + The `env_var` form of `register_configure_hook` constructs a **new** + `FailproofAITracer` per callback manager, so anything kept on `self` would be + lost between a start and its end. Nothing here touches contextvars either: + `ContextVar.reset(token)` raises across asyncio tasks as well as threads, so + a surface whose start and end are separate calls can never hold a token. + """ + + MAX_RUNS = 10_000 + MAX_SESSIONS = 1_000 + + def __init__(self) -> None: + # RLock because a handler body can re-enter through a nested helper. + self.lock = threading.RLock() + # The kill switch `uninstall()` needs and neither of its two other + # levers actually provides. A configure hook cannot be deregistered, so + # removal is "make the hook produce nothing" — but clearing the + # ContextVar only reaches contexts derived from the one calling + # `uninstall()`, and unsetting the env var is skipped whenever the + # process already had it set (`self._set_env` is False then, so we do + # not clobber somebody else's environment). Either hole leaves + # `_configure` constructing a live zero-arg tracer per callback manager + # — VERIFIED: with FAILPROOFAI_SDK_TRACE_LANGCHAIN=1 exported before + # `instrument()`, every event was still recorded after `uninstrument()`. + # Checked at the two entry points that gate everything else: no + # `_RunInfo` is registered, so `_on_end`, `_count_token`, + # `_on_interrupt` and `_on_resume` all fall out on their own lookups. + self.enabled = False + self.options = _Options() + self.tracker = RunTracker(NAME, base_fields=_base_fields()) + self.runs: dict[str, _RunInfo] = {} + self.sessions: dict[str, _Session] = {} + # run_id -> the LLMResult / exception stashed by a public callback for + # the `_end_trace` that follows it. + self.responses: dict[str, Any] = {} + self.errors: dict[str, BaseException] = {} + + def reset(self) -> None: + with self.lock: + self.tracker.reset() + self.runs.clear() + self.sessions.clear() + self.responses.clear() + self.errors.clear() + + def evict(self) -> None: + """Caller holds the lock. FIFO; dicts keep insertion order. + + Orphaned entries are normal, not exceptional: a cancelled stream, a + crashed node, a framework that skipped an end callback. Unbounded, each + of these dicts is a memory leak in a long-lived server. + """ + for table, cap in ( + (self.runs, self.MAX_RUNS), + (self.sessions, self.MAX_SESSIONS), + (self.responses, self.MAX_RUNS), + (self.errors, self.MAX_RUNS), + ): + while len(table) >= cap: + table.pop(next(iter(table)), None) + + +def _base_fields() -> dict: + fields = framework_fields(NAME, DIST) + version = _compat.version_string("langgraph") + if version: + fields["fw_langgraph_version"] = version + return fields + + +_STATE = _State() + + +# --------------------------------------------------------------------------- +# Small readers over the Run object +# --------------------------------------------------------------------------- + +def _meta(run: Any) -> dict: + meta = getattr(run, "metadata", None) + return dict(meta) if isinstance(meta, dict) else {} + + +def _extra(run: Any) -> dict: + extra = getattr(run, "extra", None) + return extra if isinstance(extra, dict) else {} + + +def _tags(run: Any) -> list: + tags = getattr(run, "tags", None) + return list(tags) if tags else [] + + +def _shrink(value: Any) -> Any: + """Payload discipline for the big three: inputs, outputs, graph state.""" + if not _STATE.options.capture_content: + return None + return truncate(value, _FIELD_LIMIT) + + +# A run of one of these types is a leaf — a model call, a tool call, a +# retrieval. It is never the LangGraph node's *own* run. Verified against +# langgraph 1.2.11: whatever you hand `add_node` (a function, a Runnable, a +# `BaseTool`, a compiled subgraph), the node's own run is always a **chain** +# run tagged `graph:step:N`, and the thing you passed runs as a child of it. +_LEAF_RUN_TYPES = frozenset({"llm", "chat_model", "tool", "retriever"}) + +# LangChain tags every step of a `RunnableSequence` `seq:step:N`. LangGraph tags +# a node's own run `graph:step:N`. Only the second is a node. +_INNER_STEP_TAG = "seq:step:" + + +def _node_of(run: Any, meta: dict) -> str | None: + """The LangGraph node name **iff this run is the node's own run**. + + Every inner Runnable inherits `langgraph_node` from the node that contains + it, so the metadata alone matches the node, the chat model inside it, the + tool it called and each conditional-edge function. `run.name` is exactly the + `kwargs["name"] or serialized["name"]` the callback was given, and only the + node's own run has it equal to `langgraph_node`. Verified against + langgraph 1.2.10: an inner `cond`/`fan` edge function reports + `name="cond"` with `langgraph_node="act"` and is correctly excluded. + + The name alone is not enough, because **the name is the user's to choose on + both sides**. Two collisions were verified on langgraph 1.2.11, and each one + silently deleted the most valuable event in the trace: + + * ``add_node("lookup_population", ToolNode([lookup_population]))`` — naming a + node after the tool it runs, which is the obvious thing to do — made the + *tool's* run match too. It was recorded as a second node visit, so + `tool_use`/`tool_result` were never emitted: the arguments, the result and + the LLM's `tool_call_id` all vanished, and `/tools` showed the call had + never happened. + * ``add_node("ChatOpenAI", ...)`` did the same to the chat model run: + no `model_request`/`model_response`, so the model name, both token counts + and the latency were dropped while the trace still looked populated. + * an inner Runnable carrying `run_name` equal to the node key produced + **two** `hook_triggered`/`hook_completed` pairs for one node visit, which + doubles that node's visit count and halves its apparent latency. + + So the run must also be shaped like a node's own run: a non-leaf run type, + and not an inner step of a `RunnableSequence`. Both are *exclusions* — if + langgraph ever stops emitting `seq:step:` tags this degrades to the old + duplicate span rather than to no spans at all, which is the safe direction + for a check that gates `hook_triggered` **and** `_ensure_subgraph_agent`. + """ + node = meta.get("langgraph_node") + if not node or node != (getattr(run, "name", None) or ""): + return None + if str(getattr(run, "run_type", "") or "") in _LEAF_RUN_TYPES: + return None + if any(str(tag).startswith(_INNER_STEP_TAG) for tag in _tags(run)): + return None + return str(node) + + +def _ns_parts(meta: dict) -> list: + """`langgraph_checkpoint_ns` split into its `name:uuid` segments. + + For a top-level node this is one segment (`plan:uuid`); for a node inside a + compiled subgraph it is `child:uuid|sub_step:uuid`. The number of segments + beyond the first is the subgraph nesting depth, and the leading segments + name the subgraphs — which is how nested agents get their ids without + having to recognise a compiled `Pregel` from a `Run` object. + """ + ns = meta.get("langgraph_checkpoint_ns") + if not ns or not isinstance(ns, str): + return [] + return ns.split("|") + + +# --------------------------------------------------------------------------- +# Session resolution +# --------------------------------------------------------------------------- + +def _resolve_session_id(run: Any, meta: dict) -> str: + """Pick the session id for a root run. + + In order: + + 1. ``instrument("langchain", session_id=...)`` — an explicit override wins. + 2. ``config={"metadata": {"failproofai_sdk_session_id": ...}}`` — the documented + per-call key. + 3. the ambient `failproofai_sdk.session()` / `failproofai_sdk.agent()` scope, so a + hand-written outer bracket and the adapter produce **one** session. + 4. ``metadata["session_id" | "conversation_id" | "thread_id"]``. + 5. the root run id. + + Never synthesised from scratch: a made-up id splits one run into many + sessions, which is a silent wrong answer rather than a loud one. + """ + if _STATE.options.session_id: + return str(_STATE.options.session_id) + explicit = meta.get(SESSION_METADATA_KEY) + if explicit: + return str(explicit) + ambient = _context.session_id() + if ambient: + return ambient + for key in SESSION_METADATA_FALLBACKS: + value = meta.get(key) + if value: + return str(value) + return str(getattr(run, "id", "")) or _context.DEFAULT_AGENT_ID + + +# --------------------------------------------------------------------------- +# Emission helpers +# --------------------------------------------------------------------------- + +def _emit(method: str, info: _RunInfo, **fields: Any) -> None: + _STATE.tracker.emit(method, info.id, parent_key=info.parent, **fields) + + +def _emit_on_agent(session: _Session, method: str, **fields: Any) -> None: + _STATE.tracker.emit(method, session.agent_key, **fields) + + +def _fw_common(run: Any, info: _RunInfo, meta: dict) -> dict: + """The `fw_*` extras every event from this adapter carries. + + Namespaced, and that is a **safety** rule rather than a style one: + `_schema._build()` merges extra fields last, so an extra called `tool_name`, + `model` or `outcome` silently overwrites the declared field and therefore + the promoted column. `_core.guard_extras` is the backstop; + `fw_fields` is how we stay away from the edge. + """ + return fw_fields( + run_id=info.id, + parent_run_id=info.parent, + node=info.node or meta.get("langgraph_node"), + step=meta.get("langgraph_step"), + checkpoint_ns=meta.get("langgraph_checkpoint_ns"), + thread_id=meta.get("thread_id"), + tags=_tags(run) or None, + hidden=True if info.hidden else None, + ) + + +# --------------------------------------------------------------------------- +# Start +# --------------------------------------------------------------------------- + +def _on_start(run: Any) -> None: + state = _STATE + if not state.enabled: + return + rid = str(run.id) + parent = str(run.parent_run_id) if getattr(run, "parent_run_id", None) else None + meta = _meta(run) + + with state.lock: + state.evict() + info = _RunInfo( + id=rid, + parent=parent, + name=str(getattr(run, "name", "") or ""), + run_type=str(getattr(run, "run_type", "") or ""), + hidden=HIDDEN_TAG in _tags(run), + ) + state.runs[rid] = info + # Every run is linked, span or not. This is what lets a tool three + # Runnables deep still find the agent above it: `RunTracker.identity` + # walks the link chain, and an intermediate chain that emits nothing + # would otherwise break the walk. + state.tracker.link(rid, parent) + + if parent is None: + _start_root(run, info, meta) + return + + holder = state.runs.get(parent) + info.root = holder.root if holder is not None else None + info.session = holder.session if holder is not None else None + + node = _node_of(run, meta) + if node is not None: + info.kind = "node" + info.node = node + _start_node(run, info, meta) + return + + if info.run_type in ("llm", "chat_model"): + info.kind = "model" + _start_model(run, info, meta) + return + if info.run_type == "tool": + info.kind = "tool" + _start_tool(run, info, meta) + return + if info.run_type == "retriever": + info.kind = "retriever" + _start_retriever(run, info, meta) + return + + # Everything else — RunnableSequence, prompt templates, output parsers, + # conditional-edge functions, the compiled-subgraph Pregel run itself. + # Emitting these would bury the timeline under machinery, so they are + # linked and otherwise invisible unless explicitly allowlisted. + if info.name and info.name in state.options.include_chains and not info.hidden: + info.kind = "chain" + _emit( + "hook_triggered", + info, + hook_name=info.name, + hook_id=info.id, + trigger_event="pipeline", + input=_shrink(getattr(run, "inputs", None)), + **_fw_common(run, info, meta), + ) + + +def _start_root(run: Any, info: _RunInfo, meta: dict) -> None: + """The root run becomes the session's agent — and its **first** event. + + `agent_sessions.agent_id = any(...)` resolves to the first `agent_id` by + time over `ORDER BY (session_id, ts, ...)`, so anything emitted before this + would name the session after a node. The dashboard also parents every leaf + to the open agent with the same `agent_id` and **synthesises a + never-ending root span** when there is none, so this must not be skipped. + """ + state = _STATE + info.kind = "root" + info.root = info.id + session_id = _resolve_session_id(run, meta) + + existing = state.sessions.get(session_id) + if ( + existing is not None + and existing.open_pauses + and existing.agent_key in state.tracker.open_agents() + and _is_continuation(run) + ): + # A resume: the previous `.invoke()` interrupted, we deliberately did + # not close its agent, and this is the continuation. Reuse the identity + # instead of opening a second root span for the same logical run. + # + # `open_pauses` is the whole test, and leaving it out was a silent + # data-loss bug rather than a cosmetic one. "The session's agent is + # still open" is ALSO true of two roots that merely OVERLAP IN TIME + # under one session id — `.batch()` (langchain-core opens one root run + # per input), a top-level `RunnableParallel` of chains, or two web + # requests carrying the same conversation id. Those were read as + # resumes: the second root got no `agent_start` at all, its work was + # relabelled with the first root's `agent_id`, the first root to finish + # closed the shared agent, and every event the other root emitted after + # that resolved to nothing and was DROPPED — a real model call, with + # its tokens and its latency, gone with one "could not resolve a + # session" line at WARNING. A run that is genuinely paused always has an + # open pause: `_end_root` returns without `agent_end` exactly when + # `session.open_pauses` is non-empty, which is the only way the agent + # stays open past its root, and `_suspend` is the only thing that fills + # it. So this distinguishes the two cases precisely. + # + # `_is_continuation` is the second half of that test and it is not + # redundant: an open pause bounds how long the window lasts, but it + # does not close it. A HITL turn can sit paused on a human for + # **minutes**, and any other run that happens to carry the same session + # id during that window — a second web request on one conversation id, + # a background summariser, a different graph entirely — was read as the + # approval. VERIFIED against langgraph 1.2.11: the second run got no + # `agent_start`, its nodes were folded into the paused run's span, and + # the adapter emitted `agent_resume` + `human_input` for a human who + # had answered nothing — `human_input.response` empty, the pause closed, + # and the paused run's `agent_end` reporting `success`. On a product + # whose whole job is to gate an action on human approval, fabricating + # the approval is the worst wrong answer available. LangGraph only ever + # continues an interrupted thread through `Command(...)` or a `None` + # input; a fresh state dict is a NEW turn, not an answer. + info.session = existing + state.tracker.link(info.id, existing.agent_key) + _resume(existing, run) + return + + identity = state.tracker.start_agent( + info.id, + agent_id=normalize_agent_id(info.name, "agent"), + session_id=session_id, + goal=_goal_of(run), + **_fw_common(run, info, meta), + ) + session = _Session( + session_id=identity.session_id or session_id, + agent_key=info.id, + agent_id=identity.agent_id or "agent", + ) + info.session = session + state.sessions[session.session_id] = session + + # This is a resume, but nothing in THIS process is paused — so the pause was + # opened somewhere else. That is not an edge case, it is the deployment + # shape: one worker serves the request that interrupts, a human answers + # minutes later, and whichever worker picks up that request resumes against + # the shared checkpointer. Before this, such a resume emitted no + # `agent_resume` and no `human_input` at all, so the `human_wait` and + # `agent_pause` from the first process stayed open FOREVER — every + # cross-process approval left its session reporting "still waiting on a + # human" after the human had answered, and `pausedMs` never closed. + # `_close_remote_pause` recovers the id the other process used. + answer = _resume_values(run) + if answer is not None: + info.remote = _RemoteResume(value=answer) + + # A root run that is ITSELF a leaf still has to be recorded as one. + # + # `ChatOpenAI(...).invoke(...)` outside any graph is a single run with no + # parent and `run_type="chat_model"`. Handled only as a root it produced an + # `agent_start`/`agent_end` pair and NOTHING ELSE — no `model_request`, no + # `model_response`, so the model name, both token counts and the latency of + # a direct model call were dropped on the floor, silently, while the trace + # still looked populated. Direct `.invoke()` is not an edge case: a + # classifier, a summariser, a one-shot rewrite are all shaped like this. + # + # The agent span stays (the dashboard parents leaves to an open agent with + # the same `agent_id` and synthesises a never-ending root span when there is + # none), so this is purely additive: the same run now emits its leaf pair + # INSIDE its own agent span. + starter = _ROOT_LEAF_STARTERS.get(info.run_type) + if starter is not None: + info.leaf_kind = _LEAF_KIND_OF[info.run_type] + starter(run, info, meta) + + +def _goal_of(run: Any) -> str | None: + inputs = getattr(run, "inputs", None) + if not _STATE.options.capture_content or inputs is None: + return None + if isinstance(inputs, dict): + messages = inputs.get("messages") + if isinstance(messages, (list, tuple)) and messages: + content = getattr(messages[-1], "content", None) + if isinstance(content, str) and content: + return truncate(content, 512) + return truncate(str(inputs), 512) + + +def _start_node(run: Any, info: _RunInfo, meta: dict) -> None: + """A LangGraph node -> `hook_triggered`. + + Also the point at which a compiled **subgraph** becomes a nested agent: a + node whose checkpoint namespace is more than one segment deep is running + inside one, and its parent run *is* the subgraph's Pregel run (verified on + langgraph 1.2.10). Deriving it here means we never have to recognise a + `Pregel` from a `Run`, and it nests to arbitrary depth for free. + """ + parts = _ns_parts(meta) + if len(parts) > 1 and info.parent is not None: + _ensure_subgraph_agent(info, parts[:-1]) + + remote = _remote_of(info) + if remote is not None: + # First node seen at this level wins: langgraph re-runs the interrupted + # tasks in the level's first superstep and nothing else (VERIFIED on + # 1.2.11 — a sibling that had already succeeded in the same superstep + # does NOT re-run), so anything at a later step is downstream work. + remote.levels.setdefault(tuple(parts[:-1]), meta.get("langgraph_step")) + + if info.hidden: + return + _emit( + "hook_triggered", + info, + hook_name=info.node, + hook_id=info.id, + trigger_event="graph_node", + input=_shrink(getattr(run, "inputs", None)), + **_fw_common(run, info, meta), + ) + + +def _ensure_subgraph_agent(info: _RunInfo, prefix: list) -> None: + state = _STATE + key = info.parent + if key is None or key in state.tracker.open_agents(): + return + holder = state.runs.get(key) + session = info.session + if holder is None or session is None: + return + names = [part.split(":", 1)[0] for part in prefix if part] + agent_id = "/".join([session.agent_id, *names]) + state.tracker.start_agent( + key, + agent_id=agent_id, + parent_key=holder.parent, + session_id=session.session_id, + **fw_fields(run_id=key, subgraph=names[-1] if names else None, kind="subgraph"), + ) + holder.kind = "subgraph" + holder.session = session + + +def _start_tool(run: Any, info: _RunInfo, meta: dict) -> None: + # The LLM-issued id when there is one, so our events line up with the + # provider's logs and with the `tool_calls` on the assistant message. It + # arrives in the `on_tool_start` kwargs and `_create_tool_run` parks the + # whole kwargs dict in `run.extra`. + info.tool_call_id = str(_extra(run).get("tool_call_id") or info.id) + if info.hidden: + return + _emit( + "tool_use", + info, + tool_name=info.name or "tool", + tool_call_id=info.tool_call_id, + input=_shrink(getattr(run, "inputs", None)), + **_fw_common(run, info, meta), + ) + + +def _start_retriever(run: Any, info: _RunInfo, meta: dict) -> None: + info.tool_call_id = info.id + if info.hidden: + return + inputs = getattr(run, "inputs", None) + query = inputs.get("query") if isinstance(inputs, dict) else inputs + _emit( + "tool_use", + info, + tool_name="retriever:%s" % (info.name or "retriever"), + tool_call_id=info.tool_call_id, + input={"query": truncate(query, _FIELD_LIMIT)} if _STATE.options.capture_content else None, + **_fw_common(run, info, meta), + ) + + +def _start_model(run: Any, info: _RunInfo, meta: dict) -> None: + info.model = _model_name(run, info, meta) + messages = _STATE.responses.pop("messages:" + info.id, None) + if messages is None: + messages = _prompts_as_messages(getattr(run, "inputs", None)) + info.messages = messages + if info.hidden: + return + _emit( + "model_request", + info, + # The correlation id the dashboard's detail panel pairs on. No SDK ever + # set it before, which is why `executionGraph` falls back to FIFO + # pairing per agent_id and concurrent calls mis-pair. + request_id=info.id, + model=info.model, + messages=messages if _STATE.options.capture_content else None, + tools=_tools_of(run), + **_fw_common(run, info, meta), + ) + + +def _model_name(run: Any, info: _RunInfo, meta: dict) -> str: + """`ls_model_name` first, then the invocation params, then the class name. + + `ls_model_name` is the LangSmith standard key and is what a real provider + integration sets. It is **absent** on the fake chat models used in tests and + on some community integrations, so the fallbacks are load-bearing rather + than defensive padding. + """ + name = meta.get("ls_model_name") + if name: + return str(name) + params = _extra(run).get("invocation_params") + if isinstance(params, dict): + for key in ("model_name", "model", "model_id", "deployment_name"): + value = params.get(key) + if value: + return str(value) + return info.name or "unknown" + + +def _tools_of(run: Any) -> list | None: + params = _extra(run).get("invocation_params") + if not isinstance(params, dict): + return None + tools = params.get("tools") + if isinstance(tools, (list, tuple)) and tools: + return truncate(list(tools), _FIELD_LIMIT) + return None + + +def _prompts_as_messages(inputs: Any) -> list | None: + """Text-completion runs arrive as `{"prompts": [...]}`. + + Chat runs are captured from `on_chat_model_start`, where the real + `BaseMessage` objects are still available — `_create_chat_model_run` + flattens them to `"System: ...\\nHuman: ..."` strings before they reach the + `Run`, which would lose the roles. + """ + if not isinstance(inputs, dict): + return None + prompts = inputs.get("prompts") + if isinstance(prompts, (list, tuple)): + return [{"role": "user", "content": truncate(p, _FIELD_LIMIT)} for p in prompts] + return None + + +_ROLES = {"human": "user", "ai": "assistant", "system": "system", "tool": "tool"} + + +def _normalize_messages(batches: Any) -> list | None: + if not batches: + return None + batch = batches[-1] if isinstance(batches[-1], (list, tuple)) else batches + out = [] + for message in batch: + kind = str(getattr(message, "type", "") or "") + entry: dict = { + "role": _ROLES.get(kind, kind or "user"), + "content": truncate(getattr(message, "content", ""), _FIELD_LIMIT), + } + calls = getattr(message, "tool_calls", None) + if calls: + entry["tool_calls"] = truncate(list(calls), _FIELD_LIMIT) + out.append(entry) + return out + + +# A root run whose own `run_type` is one of these is a leaf as well as the +# session's agent. Keyed by LangChain's `run_type` string. +_LEAF_KIND_OF = { + "llm": "model", + "chat_model": "model", + "tool": "tool", + "retriever": "retriever", +} +_ROOT_LEAF_STARTERS = { + "llm": _start_model, + "chat_model": _start_model, + "tool": _start_tool, + "retriever": _start_retriever, +} + + +# --------------------------------------------------------------------------- +# End +# --------------------------------------------------------------------------- + +def _on_end(run: Any) -> None: + state = _STATE + rid = str(run.id) + with state.lock: + info = state.runs.get(rid) + exc = state.errors.pop(rid, None) + response = state.responses.pop(rid, None) + if info is None: + return + meta = _meta(run) + + if info.kind == "root": + # Close the leaf pair first when the root was also a leaf: the + # dashboard closes the agent span at `agent_end`, so a + # `model_response` emitted after it is attributed to nothing. + if info.leaf_kind: + _ROOT_LEAF_ENDERS[info.leaf_kind](run, info, meta, exc, response) + # ...and the leaf pair we just closed OWNS the failure, exactly + # as it does for a nested tool or model run (see the matching + # line at the bottom of this function). Without this, a failing + # `tool.invoke()` or `llm.invoke()` at the top level reported + # the same exception twice — once as `tool_result.error` and + # again as a standalone `error` event — so one failure counted + # as two on `sessionSummary.errorCount`, while the identical + # failure one Runnable deeper counted as one. + if info.session is not None and not _is_control_flow(exc) and ( + exc is not None or getattr(run, "error", None) + ): + info.session.reported_error = True + _end_root(run, info, meta, exc) + return + + state.runs.pop(rid, None) + + if info.kind == "subgraph" or rid in state.tracker.open_agents(): + state.tracker.end_agent( + rid, + outcome=_outcome(run, exc), + summary=_error_text(run, exc), + ) + elif info.hidden: + pass + elif info.kind == "node" or info.kind == "chain": + _end_hook(run, info, meta, exc) + elif info.kind == "tool": + _end_tool(run, info, meta, exc) + elif info.kind == "retriever": + _end_retriever(run, info, meta, exc) + elif info.kind == "model": + _end_model(run, info, meta, exc, response) + + if info.kind == "node": + # Strictly BEFORE the `_suspend` below: a node that answers one + # interrupt and immediately raises the next must close the old pause + # before opening the new one, and on langgraph 1.2 both carry the + # same id (it is derived from the task's namespace, not the call). + _close_remote_pause(info, meta) + + # The exception-path HITL fallback, deliberately outside the span + # handling above so that it still fires for a `langsmith:hidden` node + # and for a subgraph that bubbled the interrupt up. `_suspend` dedups on + # `Interrupt.id`, so this and `on_interrupt` cannot double-emit. + interrupts = _interrupts_of(exc) + if interrupts and info.session is not None: + _suspend(info.session, interrupts) + if exc is not None and not _is_control_flow(exc) and info.session is not None: + # The span that owns this failure has reported it. The root must not + # report it again, or `sessionSummary.errorCount` counts one failure + # twice — once on the leaf and once as a standalone `error` event. + info.session.reported_error = True + + +def _outcome(run: Any, exc: BaseException | None) -> str: + if _is_control_flow(exc): + return "paused" + if exc is not None or getattr(run, "error", None): + return "failed" + return "success" + + +def _error_text(run: Any, exc: BaseException | None) -> str | None: + """The error as a short string, never the whole stacktrace. + + `run.error` is `repr(exc)` plus the formatted traceback, which is a fine + thing to keep on the `traceback` field of an `error` event and a terrible + thing to put in `tool_result.error`, where the dashboard renders it inline. + """ + if _is_control_flow(exc): + return None + if exc is not None: + return truncate("%s: %s" % (type(exc).__name__, exc), _FIELD_LIMIT) + error = getattr(run, "error", None) + if error: + return truncate(str(error).splitlines()[0], _FIELD_LIMIT) + return None + + +def _error_message(run: Any, exc: BaseException | None) -> str | None: + """`_error_text` minus the type prefix, for the `error` event only. + + `error` is the one event that carries `error_type` as its OWN field, and the + server builds the row's `summary` as ``"<error_type>: <message>"``. Feeding + it `_error_text` — which prefixes the type because `tool_result.error` and + `agent_end.summary` have nowhere else to say it — rendered every entry on + the Errors surface as ``ValueError: ValueError: denominator must be + non-zero``. The CrewAI, LlamaIndex and Pydantic AI adapters all pass a bare + `str(exc)` here; this makes the fourth agree with them. + """ + if _is_control_flow(exc): + return None + if exc is not None: + return truncate(str(exc), _FIELD_LIMIT) or type(exc).__name__ + error = getattr(run, "error", None) + if error: + return truncate(str(error).splitlines()[0], _FIELD_LIMIT) + return None + + +def _end_hook(run: Any, info: _RunInfo, meta: dict, exc: BaseException | None) -> None: + _emit( + "hook_completed", + info, + hook_name=info.node or info.name, + hook_id=info.id, + # `"paused"` for a GraphInterrupt: the node did not fail, it stopped to + # ask a human. `"failed"`, never `"failure"` — the server only counts + # error|failed|timeout|rejected. + outcome=_outcome(run, exc), + output=_shrink(getattr(run, "outputs", None)), + error=_error_text(run, exc), + **_fw_common(run, info, meta), + ) + + +def _end_tool(run: Any, info: _RunInfo, meta: dict, exc: BaseException | None) -> None: + outputs = getattr(run, "outputs", None) + output = outputs.get("output") if isinstance(outputs, dict) else outputs + output, failed = _tool_output(output) + _emit( + "tool_result", + info, + tool_name=info.name or "tool", + tool_call_id=info.tool_call_id or info.id, + output=_shrink(output), + error=_error_text(run, exc) or failed, + **_fw_common(run, info, meta), + ) + + +def _tool_output(output: Any) -> tuple: + """The tool's actual result, plus an error string when it failed quietly. + + A tool invoked the way every modern tool loop invokes one — handed the + LLM's `ToolCall` dict rather than a bare argument dict, which is what + `bind_tools` produces and what the docs show — returns a **`ToolMessage`**, + not a string. `truncate` has no JSON shape for one, so it fell back to + `repr` and the single most-read field in a tool loop rendered as + ``ToolMessage(content='37000000', name='lookup_population', tool_call_id=…)`` + instead of ``37000000``. + + `status` is the second half. A `ToolMessage` carries `status="error"` when + the tool failed but the framework converted the exception into a message + for the model instead of raising — `run.error` is empty on that path, so the + failure had NO representation at all: `is_error` 0, a green span, and the + text of the exception sitting in an output field nobody filters on. + """ + if getattr(output, "type", None) != "tool": + return output, None + content = getattr(output, "content", None) + failed = None + if getattr(output, "status", None) == "error": + failed = truncate(content if isinstance(content, str) else str(content), _FIELD_LIMIT) + return content, failed + + +def _end_retriever(run: Any, info: _RunInfo, meta: dict, exc: BaseException | None) -> None: + _emit( + "tool_result", + info, + tool_name="retriever:%s" % (info.name or "retriever"), + tool_call_id=info.tool_call_id or info.id, + output=_summarize_documents(getattr(run, "outputs", None)), + error=_error_text(run, exc), + **_fw_common(run, info, meta), + ) + + +def _summarize_documents(outputs: Any) -> dict | None: + """`{"n": ..., "sources": [...]}` — never the document text. + + A retriever that returns twenty 4KB chunks would otherwise put 80KB of + prose into one event, on every hop of every RAG loop. None of it is a + promoted column, so querying it means `JSONExtract` over the payload, which + has already caused a memory blowup in the events store in this product. + """ + if not isinstance(outputs, dict): + return None + docs = outputs.get("documents") + if not isinstance(docs, (list, tuple)): + return None + sources = [] + for index, doc in enumerate(docs[:10]): + meta = getattr(doc, "metadata", None) or {} + source = meta.get("source") or meta.get("id") or meta.get("file_path") + sources.append(truncate(str(source) if source else "doc[%d]" % index, 256)) + return {"n": len(docs), "sources": sources} + + +def _end_model( + run: Any, info: _RunInfo, meta: dict, exc: BaseException | None, response: Any +) -> None: + usage = _usage(response) + content, role, stop_reason = _completion(response) + if exc is not None: + stop_reason = "error" + extras = _fw_common(run, info, meta) + if info.chunks: + extras.update(fw_fields(streamed=True, chunks=info.chunks, ttft_ms=info.ttft_ms)) + _emit( + "model_response", + info, + request_id=info.id, + model=info.model, + stop_reason=stop_reason, + content=content if _STATE.options.capture_content else None, + role=role, + input_tokens=usage.get("input_tokens") if usage else None, + output_tokens=usage.get("output_tokens") if usage else None, + # Shipped as a dict as well: both `event_summary.rs` and + # `sessionSummary.ts` fall back to `payload.usage` for tokens. + usage=usage or None, + error=_error_text(run, exc), + # ALWAYS set, and always an `int`. `duration_ms` is not guarded on + # `model_response`, and `durationOf` prefers the closing event's value + # over end-minus-start — which is what keeps model durations honest even + # though the execution graph pairs model events FIFO per agent_id. A + # float would silently NULL the promoted u32 column. + duration_ms=_duration_ms(run), + **extras, + ) + + +def _duration_ms(run: Any) -> int: + start = getattr(run, "start_time", None) + if start is None: + return 0 + # `_errored_llm_run` does not set `end_time`, unlike every other errored + # path, so an errored model call would report a 0ms duration without this. + end = getattr(run, "end_time", None) or _now() + return ms(end - start) + + +def _usage(response: Any) -> dict: + """Normalise token counts across the three shapes providers actually use. + + Primary is `usage_metadata` on the message — the LangChain-standard shape + since 0.3 and the only one that carries cache/reasoning detail. The two + fallbacks are OpenAI's `prompt_tokens`/`completion_tokens` and Anthropic's + `input_tokens`/`output_tokens`, both of which arrive under `llm_output`. + """ + if response is None: + return {} + message = _first_message(response) + data = getattr(message, "usage_metadata", None) + if isinstance(data, dict) and data: + usage = { + "input_tokens": data.get("input_tokens"), + "output_tokens": data.get("output_tokens"), + "total_tokens": data.get("total_tokens"), + } + for key in ("input_token_details", "output_token_details"): + if data.get(key): + usage[key] = dict(data[key]) + return {k: v for k, v in usage.items() if v is not None} + + output = getattr(response, "llm_output", None) + if not isinstance(output, dict): + return {} + raw = output.get("token_usage") or output.get("usage") or {} + if not isinstance(raw, dict): + return {} + # Built as an explicitly `int`-valued dict rather than filtered in place: + # the `isinstance` filter narrows at runtime but not for a type checker, and + # the arithmetic below is the kind of thing that must not be `Any`. + counts: dict[str, int] = { + name: value + for name, value in ( + ("input_tokens", raw.get("prompt_tokens", raw.get("input_tokens"))), + ("output_tokens", raw.get("completion_tokens", raw.get("output_tokens"))), + ("total_tokens", raw.get("total_tokens")), + ) + if isinstance(value, int) and not isinstance(value, bool) + } + if counts and "total_tokens" not in counts: + counts["total_tokens"] = counts.get("input_tokens", 0) + counts.get("output_tokens", 0) + return counts + + +def _first_generation(response: Any) -> Any: + generations = getattr(response, "generations", None) + if not generations: + return None + first = generations[0] + if isinstance(first, (list, tuple)): + return first[0] if first else None + return first + + +def _first_message(response: Any) -> Any: + generation = _first_generation(response) + return getattr(generation, "message", None) if generation is not None else None + + +def _completion(response: Any) -> tuple: + generation = _first_generation(response) + if generation is None: + return None, None, None + message = getattr(generation, "message", None) + content = getattr(message, "content", None) + if content is None: + content = getattr(generation, "text", None) + info = getattr(generation, "generation_info", None) or {} + stop = info.get("finish_reason") or info.get("stop_reason") + if not stop and message is not None: + response_meta = getattr(message, "response_metadata", None) or {} + stop = response_meta.get("finish_reason") or response_meta.get("stop_reason") + role = "assistant" if message is not None else None + return truncate(content, _FIELD_LIMIT), role, stop + + +def _end_root(run: Any, info: _RunInfo, meta: dict, exc: BaseException | None) -> None: + state = _STATE + session = info.session + state.runs.pop(info.id, None) + _close_open_leaves(info.id) + if session is None: + return + + if session.open_pauses: + # Interrupted, waiting on a human. Deliberately no `agent_end`: closing + # the agent here would force-close the open pause (the graph does that + # at `agent_end`), zeroing the one interval that measures how long the + # human took. The agent is closed by the resuming `.invoke()`. + return + + failed = exc is not None and not _is_control_flow(exc) + if not failed and getattr(run, "error", None) and not _is_control_flow(exc): + failed = True + + if failed and not session.reported_error: + # Nothing below reported this failure, so nobody owns it — a standalone + # `error` event is the only way it reaches the Errors surface. Strictly + # before `agent_end`: the graph closes the agent span at `agent_end`, + # so an error after it is attributed to nothing. + _emit_on_agent( + session, + "error", + error_type=type(exc).__name__ if exc is not None else "RunError", + message=_error_message(run, exc) or "run failed", + traceback=truncate(str(getattr(run, "error", "") or ""), _core.FIELD_LIMIT) or None, + **_fw_common(run, info, meta), + ) + + state.tracker.end_agent( + session.agent_key, + outcome="failed" if failed else "success", + summary=_error_text(run, exc) if failed else None, + **_fw_common(run, info, meta), + ) + state.sessions.pop(session.session_id, None) + + +# Mirrors `_ROOT_LEAF_STARTERS`. `_end_tool`/`_end_retriever` take no response +# argument, so they are adapted to one signature here rather than at the call +# site — a mismatch would be swallowed by `safe()` and read as "no events". +_ROOT_LEAF_ENDERS = { + "model": _end_model, + "tool": lambda run, info, meta, exc, response: _end_tool(run, info, meta, exc), + "retriever": lambda run, info, meta, exc, response: _end_retriever(run, info, meta, exc), +} + + +def _close_open_leaves(root_id: str) -> None: + """Close every leaf still open under this root. Caller holds the lock. + + `agent_end` force-closes open *pauses* but not tools, models or humans, so a + run that dies mid-tool leaves the session `ongoing` forever. Closing them + here is what keeps that invariant true when a framework skips an end + callback — which happens on a hard cancellation, a killed stream, or a + handler that was disabled part-way through by the failure policy. + """ + state = _STATE + stale = [i for i in state.runs.values() if i.root == root_id and i.id != root_id] + for info in reversed(stale): + state.runs.pop(info.id, None) + if info.hidden or not info.kind: + continue + marker = fw_fields(incomplete=True) + try: + if info.kind == "tool" or info.kind == "retriever": + _emit( + "tool_result", + info, + tool_name=info.name or "tool", + tool_call_id=info.tool_call_id or info.id, + **marker, + ) + elif info.kind in ("node", "chain"): + _emit( + "hook_completed", + info, + hook_name=info.node or info.name, + hook_id=info.id, + outcome="cancelled", + **marker, + ) + elif info.kind == "model": + _emit( + "model_response", + info, + request_id=info.id, + model=info.model, + stop_reason="incomplete", + duration_ms=ms(_now() - info.started), + **marker, + ) + elif info.kind == "subgraph": + state.tracker.end_agent(info.id, outcome="cancelled", **marker) + except Exception: # pragma: no cover - teardown must never raise + logger.debug("failproofai_sdk: could not close open span %s", info.id, exc_info=True) + + +# --------------------------------------------------------------------------- +# Human in the loop +# --------------------------------------------------------------------------- + +def _interrupts_of(exc: BaseException | None) -> tuple: + """The `Interrupt`s carried by a `GraphInterrupt`, if this is one. + + `GraphInterrupt.__init__` does `super().__init__(interrupts)`, so + `exc.args[0]` is the sequence. `ParentCommand` and `GraphDrained` are also + `GraphBubbleUp` but carry a `Command` / a reason string, so the duck-typed + `.value` check is what keeps them out. + """ + if not _is_control_flow(exc): + return () + args = getattr(exc, "args", ()) or () + if not args: + return () + candidates = args[0] + if not isinstance(candidates, (list, tuple)): + return () + return tuple(c for c in candidates if hasattr(c, "value")) + + +def _suspend(session: _Session, interrupts: Iterable) -> None: + """`human_wait` + `agent_pause`, one pair per `Interrupt`, in that order. + + **Both** pairs are required and neither is redundant: only + `agent_pause` -> `agent_resume` feeds the graph's `pausedMs` (without it the + session reports `ongoing` and inflates its active duration by the whole + human wait), and only `human_wait` -> `human_input` carries the prompt, the + response and the `pendingHuman` count. + """ + for index, interrupt in enumerate(interrupts): + pause_id = str(getattr(interrupt, "id", None) or "%s:%d" % (session.agent_key, index)) + if pause_id in session.open_pauses: + continue + prompt, options = _prompt_of(getattr(interrupt, "value", None)) + session.open_pauses[pause_id] = prompt + _emit_on_agent( + session, + "human_wait", + input_id=pause_id, + prompt=prompt, + options=options, + reason="langgraph_interrupt", + **fw_fields(interrupt_id=pause_id, kind="interrupt"), + ) + _emit_on_agent( + session, + "agent_pause", + pause_id=pause_id, + reason="langgraph_interrupt", + **fw_fields(interrupt_id=pause_id), + ) + + +def _prompt_of(value: Any) -> tuple: + if isinstance(value, dict): + prompt = value.get("prompt") or value.get("question") or value.get("message") + options = value.get("options") + if not isinstance(options, (list, tuple)): + options = None + else: + options = [str(o) for o in options] + if prompt is not None: + return truncate(str(prompt), _FIELD_LIMIT), options + return truncate(str(value), _FIELD_LIMIT), options + return truncate(str(value), _FIELD_LIMIT) if value is not None else None, None + + +def _resume(session: _Session, run: Any) -> None: + """`agent_resume` + `human_input`, in that order, one pair per open pause.""" + if not session.open_pauses: + return + answers = _resume_values(run) + for pause_id, prompt in list(session.open_pauses.items()): + session.open_pauses.pop(pause_id, None) + _emit_on_agent( + session, + "agent_resume", + pause_id=pause_id, + reason="langgraph_resume", + **fw_fields(interrupt_id=pause_id), + ) + _emit_on_agent( + session, + "human_input", + input_id=pause_id, + response=_answer_for(answers, pause_id), + **fw_fields(interrupt_id=pause_id, prompt=prompt), + ) + + +_MISSING = object() + + +def _steering_value(run: Any) -> Any: + """The object `.invoke()` was called with, when it was **not** fresh state. + + Verified on langgraph 1.2.11: a fresh turn arrives as the state mapping + itself (``{'trail': []}``), while anything that is not a mapping is wrapped + under a single ``input`` key — ``{'input': Command(resume='yes')}`` for a + resume, ``{'input': None}`` for ``invoke(None, config)``. So the presence of + that key is what separates "steering an existing checkpointed run" from + "starting a new one", and it is a *positive* test rather than a guess at + which state schemas happen to look like a Command. + """ + inputs = getattr(run, "inputs", None) + if isinstance(inputs, dict): + return inputs.get("input", _MISSING) + return inputs if inputs is not None else _MISSING + + +def _is_continuation(run: Any) -> bool: + """True when this root run continues an interrupted thread. + + LangGraph has exactly two of these — ``Command(...)`` and ``None`` — and + both are shaped unlike fresh state (see `_steering_value`). Everything else + starts a new run even when it lands on a thread that is mid-interrupt: a + fresh input discards the pending tasks rather than answering them. + """ + value = _steering_value(run) + if value is _MISSING: + return False + if value is None: + return True + if _Command is not None and isinstance(value, _Command): + return True + # Duck-typed fallback for a moved/renamed `Command`. + return all(hasattr(value, name) for name in ("resume", "goto", "update")) + + +def _resume_values(run: Any) -> Any: + """The value handed to `Command(resume=...)`, read off the root run's input. + + On a resume, langgraph calls `on_chain_start` with the `Command` itself as + the input, so the human's answer is available to us without any cooperation + from the caller. `Command(resume={interrupt_id: value})` (the multi- + interrupt form) is handled by `_answer_for`. + """ + value = _steering_value(run) + return getattr(value, "resume", None) if value is not _MISSING else None + + +def _answer_for(answers: Any, pause_id: str) -> str | None: + if answers is None: + return None + if isinstance(answers, dict) and pause_id in answers: + return truncate(str(answers[pause_id]), _FIELD_LIMIT) + return truncate(str(answers), _FIELD_LIMIT) + + +def _session_for_run(run_id: Any) -> _Session | None: + info = _STATE.runs.get(str(run_id)) if run_id is not None else None + return info.session if info is not None else None + + +# --------------------------------------------------------------------------- +# Human in the loop, resumed by a DIFFERENT PROCESS +# --------------------------------------------------------------------------- +# +# Everything above assumes the process that paused is the process that resumes, +# because it keys the pause on the `Interrupt` object it saw. Real HITL is not +# shaped like that: the interrupt is served by one worker, a human answers +# minutes or hours later, and any worker may pick that request up. The resuming +# process has no `_Session`, no `open_pauses`, and langgraph's `GraphResumeEvent` +# carries a checkpoint id but no interrupt ids — so there was nothing to +# correlate on and the pause simply stayed open forever. +# +# It is recoverable, exactly, because `Interrupt.id` is not random. langgraph +# 1.2's `interrupt()` builds it with `Interrupt.from_ns(value, ns)`, i.e. +# `xxh3_128(checkpoint_ns)` — a pure function of the interrupted task's +# namespace. That namespace is `metadata["langgraph_checkpoint_ns"]`, which this +# adapter already reads on every node run, and it is **byte-identical across the +# two invocations** (VERIFIED on langgraph 1.2.11: `approve:49c9e42f-…` in both +# the interrupting and the resuming process, hashing to the id the first process +# reported). So the resuming process can reconstruct the id the pausing process +# used without any shared state at all. +# +# The remaining question is *which* node re-ran because it was interrupted, and +# langgraph answers that too, in two parts: +# +# * `on_resume` fires once per Pregel level, in order, each naming the level's +# checkpoint namespace, and always **before** the node runs at that level. An +# interrupt inside a subgraph therefore produces `ns=()` then +# `ns=('child:…',)`, and the deepest of those is the graph that actually +# paused — which is how the subgraph HOST node (a normal node at the shallower +# level) is excluded. +# * only the level's first superstep re-runs interrupted tasks. A sibling that +# had already succeeded in that superstep does not re-run at all, and +# downstream nodes are at later steps. +# +# Deliberately decided at node **end** rather than start: a subgraph host node +# starts before the deeper `on_resume` that unmasks it, so at start time it is +# indistinguishable from the interrupted task. The cost is that `agent_resume` +# lands after the resumed node's own body, which adds that node's duration to +# the measured wait — a rounding error against a human, and the only alternative +# is guessing. + + +def _remote_of(info: _RunInfo) -> _RemoteResume | None: + """The `_RemoteResume` of this run's root, if the root is one.""" + root = _STATE.runs.get(info.root) if info.root else None + return root.remote if root is not None else None + + +def _interrupt_id_of(ns: str) -> str | None: + if _Interrupt is None or not ns: + return None + try: + return str(_Interrupt.from_ns(None, ns).id) + except Exception: # pragma: no cover - a future langgraph changing the shape + logger.debug("failproofai_sdk: could not derive an interrupt id", exc_info=True) + return None + + +def _close_remote_pause(info: _RunInfo, meta: dict) -> None: + """`agent_resume` + `human_input` for a pause this process never opened.""" + remote = _remote_of(info) + session = info.session + if remote is None or session is None or remote.deepest is None: + return + parts = _ns_parts(meta) + level = tuple(parts[:-1]) + if level != remote.deepest: + return + if meta.get("langgraph_step") != remote.levels.get(level): + return + pause_id = _interrupt_id_of(meta.get("langgraph_checkpoint_ns") or "") + if pause_id is None or pause_id in remote.done: + return + remote.done.add(pause_id) + marker = fw_fields(interrupt_id=pause_id, resumed_elsewhere=True) + _emit_on_agent( + session, "agent_resume", pause_id=pause_id, reason="langgraph_resume", **marker + ) + _emit_on_agent( + session, + "human_input", + input_id=pause_id, + response=_answer_for(remote.value, pause_id), + **marker, + ) + + +# --------------------------------------------------------------------------- +# The handler +# --------------------------------------------------------------------------- + +class FailproofAITracer(BaseTracer, _GraphBase): + """The single sync handler. Zero-arg, cheap, and stateless by design. + + Every override is one of two shapes: + + * ``_start_trace`` / ``_end_trace`` — call `super()` and hand the assembled + `Run` to a module-level translator wrapped in `_core.safe`. `super()` is + called **unconditionally and outside** our own work, so a bug in the + translator can never skip LangChain's own bookkeeping. + * ``on_*`` — stash the one thing the `Run` object does not preserve (the + exception object, the `LLMResult`, the un-flattened chat messages), then + delegate. These exist because `Run.error` is a formatted traceback rather + than the exception, and we need `isinstance(exc, GraphBubbleUp)` to tell a + human-approval pause from a failure. + + Nothing here holds a contextvar token: `ContextVar.reset()` raises across + tasks as well as threads, and every one of these callbacks can land on a + different task from the one that opened the run. + """ + + # Non-negotiable. See the module docstring: without it AsyncCallbackManager + # dispatches us through run_in_executor and can reorder our callbacks. + run_inline = True + + @property + def raise_error(self) -> bool: # type: ignore[override] + """False normally; True under FAILPROOFAI_SDK_STRICT. + + Normally False so an adapter bug can never take down the customer's + graph: LangChain catches, logs and swallows handler exceptions, and + `_core.safe` does the same one layer further in. + + But that firewall also made `FAILPROOFAI_SDK_STRICT=1` inert *specifically + here*. `safe()` re-raises under strict, and LangChain's `handle_event` + then caught it and logged "Error in FailproofAITracer.<cb> callback", so + the fault never reached the caller and the escape hatch silently did + nothing on the one adapter people are most likely to debug. Following + strict mode restores it. Read per callback by LangChain, so toggling + the env var takes effect without re-instrumenting. + """ + return _core.strict() + + def _persist_run(self, run: Any) -> None: + """Required by `BaseTracer`; we stream, so there is nothing to persist.""" + + def _start_trace(self, run: Any) -> None: + super()._start_trace(run) + _on_start(run) + + def _end_trace(self, run: Any) -> None: + _on_end(run) + super()._end_trace(run) + + def on_chat_model_start( + self, + serialized: dict, + messages: list, + *, + run_id: Any, + tags: list | None = None, + parent_run_id: Any = None, + metadata: dict | None = None, + name: str | None = None, + **kwargs: Any, + ) -> Any: + _stash_messages(run_id, messages) + return super().on_chat_model_start( + serialized, + messages, + run_id=run_id, + tags=tags, + parent_run_id=parent_run_id, + metadata=metadata, + name=name, + **kwargs, + ) + + def on_llm_end(self, response: Any, *, run_id: Any, **kwargs: Any) -> Any: + _stash(run_id, response) + return super().on_llm_end(response, run_id=run_id, **kwargs) + + def on_llm_error(self, error: BaseException, *, run_id: Any, **kwargs: Any) -> Any: + _stash_error(run_id, error) + return super().on_llm_error(error, run_id=run_id, **kwargs) + + def on_chain_error( + self, error: BaseException, *, inputs: dict | None = None, run_id: Any, **kwargs: Any + ) -> Any: + _stash_error(run_id, error) + return super().on_chain_error(error, inputs=inputs, run_id=run_id, **kwargs) + + def on_tool_error(self, error: BaseException, *, run_id: Any, **kwargs: Any) -> Any: + _stash_error(run_id, error) + return super().on_tool_error(error, run_id=run_id, **kwargs) + + def on_retriever_error(self, error: BaseException, *, run_id: Any, **kwargs: Any) -> Any: + _stash_error(run_id, error) + return super().on_retriever_error(error, run_id=run_id, **kwargs) + + def _on_llm_new_token(self, run: Any, token: Any, chunk: Any) -> None: + """Folded into the closing `model_response`. **Never** an event. + + A 500-token response would otherwise be 500 stored rows and 500 rail + rows against a five-lane cap. Langfuse uses this callback only to stamp + time-to-first-token; so do we. + """ + _count_token(run) + + def on_interrupt(self, event: Any) -> None: + _on_interrupt(event) + + def on_resume(self, event: Any) -> None: + _on_resume(event) + + +# The translators, each individually guarded. `safe()` catches `Exception` and +# **not** `BaseException`: `CancelledError`, `KeyboardInterrupt` and +# `SystemExit` are BaseExceptions, and swallowing them here would silently break +# cancellation in every instrumented async application. +_on_start = safe(_on_start) +_on_end = safe(_on_end) + + +@safe +def _stash(run_id: Any, response: Any) -> None: + with _STATE.lock: + _STATE.responses[str(run_id)] = response + + +@safe +def _stash_messages(run_id: Any, messages: Any) -> None: + # The one stash `_on_end` does not clean up after itself: it pops `rid` and + # `messages:` is a different key, drained only by `_start_model`. So it is + # the one that has to honour the kill switch too, or a torn-down adapter + # grows a dict forever. + if not _STATE.enabled: + return + with _STATE.lock: + _STATE.responses["messages:" + str(run_id)] = _normalize_messages(messages) + + +@safe +def _stash_error(run_id: Any, error: BaseException) -> None: + with _STATE.lock: + _STATE.errors[str(run_id)] = error + + +@safe +def _count_token(run: Any) -> None: + with _STATE.lock: + info = _STATE.runs.get(str(run.id)) + if info is None: + return + info.chunks += 1 + if info.ttft_ms is None: + start = getattr(run, "start_time", None) + info.ttft_ms = ms(_now() - start) if start is not None else 0 + + +@safe +def _on_interrupt(event: Any) -> None: + with _STATE.lock: + session = _session_for_run(getattr(event, "run_id", None)) + if session is None: + return + _suspend(session, getattr(event, "interrupts", ()) or ()) + + +@safe +def _on_resume(event: Any) -> None: + # Two jobs. The first is normally a no-op: the resuming root run starts + # *before* langgraph drains its lifecycle queue, so `_start_root` has + # already closed a pause this process opened. That is here for the ordering + # not holding in some future version, and `_resume` returns immediately when + # there is nothing open. + # + # The second is load-bearing, and is the only signal that separates the + # subgraph HOST node from the task that actually paused: this event names + # the Pregel level that is resuming, and fires once per level, deepest last. + with _STATE.lock: + info = _STATE.runs.get(str(getattr(event, "run_id", None) or "")) + if info is None: + return + # `_remote_of` resolves through `info.root`, which a root run sets to + # its own id, so this covers both the root's event and a subgraph's. + remote = _remote_of(info) + if remote is not None: + level = tuple(getattr(event, "checkpoint_ns", ()) or ()) + if remote.deepest is None or len(level) >= len(remote.deepest): + remote.deepest = level + if info.session is not None: + _resume(info.session, None) + + +# --------------------------------------------------------------------------- +# Install / uninstall +# --------------------------------------------------------------------------- + +_HANDLER_VAR: contextvars.ContextVar = contextvars.ContextVar( + "failproofai_langchain_handler", default=None +) + +_hook_lock = threading.Lock() +_hook_registered = False + +# The instance `install()` created, kept outside the ContextVar so that a worker +# thread — which starts with a fresh context and therefore an empty var — can +# still find it. `_configure` itself does not need this (it constructs a fresh +# zero-arg handler from the env var), but the graph-lifecycle wrap does, because +# langgraph filters on `isinstance`, not on a class. +_ACTIVE_HANDLER: Any = None + + +def _register_hook() -> None: + """`register_configure_hook` exactly once per process. + + `_configure_hooks` is a module-level list with no removal API, so calling + this twice means two entries — and although `_configure`'s `isinstance` + dedup would keep the handler count at one, the list would grow on every + `instrument()`/`uninstrument()` cycle in a reloading dev server. + """ + global _hook_registered + with _hook_lock: + if _hook_registered: + return + register_configure_hook(_HANDLER_VAR, True, FailproofAITracer, ENV_VAR) + _hook_registered = True + + +@safe +def _attach_graph_handler(manager: Any) -> None: + handler = _HANDLER_VAR.get() or _ACTIVE_HANDLER + if handler is None or manager is None: + return + handlers = getattr(manager, "handlers", None) + if handlers is None or any(isinstance(h, FailproofAITracer) for h in handlers): + return + manager.add_handler(handler, True) + + +def _install_graph_callbacks(patcher: Patcher) -> bool: + """Make `on_interrupt`/`on_resume` reach a globally-installed handler. + + Verified on langgraph 1.2.10: `Pregel.stream` calls + `get_sync_graph_callback_manager_for_config(config)`, which filters the + **raw** `config["callbacks"]` for `GraphCallbackHandler` instances. A + handler injected by `register_configure_hook` is never in there — the hook + runs inside `CallbackManager.configure`, which builds a *different* + manager — so without this wrap the lifecycle callbacks are dead code for + every user who did not pass the handler by hand. Worse, langgraph gates the + feature entirely on `has_graph_lifecycle_callbacks=bool(manager.handlers)`. + + We patch the names as they are bound in `langgraph.pregel.main` (a + `from ... import`, so patching `langgraph.callbacks` would have no effect) + and only when they are still the same objects, so a refactor upstream + degrades to "no lifecycle callbacks" rather than to a wrong patch. + """ + import langgraph.callbacks as lgcb + import langgraph.pregel.main as pmain + + names = ( + "get_sync_graph_callback_manager_for_config", + "get_async_graph_callback_manager_for_config", + ) + for name in names: + bound = getattr(pmain, name, None) + if bound is None or bound is not getattr(lgcb, name, None): + return False + for name in names: + original = getattr(pmain, name) + patcher.patch( + pmain, + name, + # `wrap_callable` is the structural guarantee: the original call is + # the only thing inside the try, and `_attach_graph_handler` runs + # outside it and inside `call_safely`. The manager is mutated in + # place, so nothing about the returned object changes. + _core.wrap_callable(original, after=lambda _ctx, manager: _attach_graph_handler(manager)), + ) + return True + + +class _Adapter: + """The object `failproofai_sdk.integrations` looks for as `adapter`.""" + + name = NAME + module = MODULE + + def __init__(self) -> None: + self._patcher = Patcher() + self._handler: FailproofAITracer | None = None + self._set_env = False + + def install(self, **options: Any) -> None: + _compat.check_version( + NAME, + DIST, + minimum="1.4.7", + below="2", + reason="langgraph 1.2's own floor; earlier cores lack the metadata this adapter reads", + ) + _compat.check_version(NAME, "langgraph", minimum="1.2", below="2", reason="GraphCallbackHandler") + + _STATE.options = _read_options(options) + _STATE.reset() + _STATE.tracker = RunTracker(NAME, base_fields=_base_fields()) + _STATE.enabled = True + + global _ACTIVE_HANDLER + _register_hook() + self._handler = FailproofAITracer() + _ACTIVE_HANDLER = self._handler + _HANDLER_VAR.set(self._handler) + # The ContextVar only reaches contexts derived from this one, so a + # worker thread started later would not see it. The env var is what + # covers those: `_configure` constructs a fresh zero-arg handler when + # the var is empty, which is safe precisely because all state is in + # `_STATE` rather than on the instance. + if ENV_VAR not in os.environ: + os.environ[ENV_VAR] = "1" + self._set_env = True + + if _STATE.options.graph_callbacks and _GraphCallbackHandler is not None: + if _compat.probe(NAME, "graph_lifecycle_callbacks", lambda: _install_graph_callbacks(self._patcher)): + logger.debug("failproofai_sdk: langgraph interrupt/resume callbacks wired") + + def uninstall(self) -> None: + # There is no deregister API for a configure hook — `_configure_hooks` + # is append-only and private — so removal is "make the hook produce + # nothing": flip the kill switch, clear the ContextVar, unset the env + # var. The switch goes FIRST and is the only one of the three that + # cannot be routed around (see `_State.enabled`); it is flipped before + # `_close_everything()` because that path emits through the tracker + # directly and never re-enters `_on_start`. + global _ACTIVE_HANDLER + _STATE.enabled = False + _ACTIVE_HANDLER = None + _HANDLER_VAR.set(None) + if self._set_env: + os.environ.pop(ENV_VAR, None) + self._set_env = False + self._patcher.restore_all() + self._handler = None + _close_everything() + _STATE.reset() + _STATE.options = _Options() + + +def _read_options(options: dict) -> _Options: + include = options.get("include_chains") or () + if isinstance(include, str): + include = (include,) + unknown = set(options) - {"session_id", "include_chains", "capture_content", "graph_callbacks"} + if unknown: + # Not fatal: `instrument()` with no name installs every detected + # adapter with the same **options, so an option meant for CrewAI + # legitimately arrives here. + logger.debug("failproofai_sdk: langchain adapter ignoring options %s", sorted(unknown)) + return _Options( + session_id=options.get("session_id"), + include_chains=frozenset(str(name) for name in include), + capture_content=bool(options.get("capture_content", True)), + graph_callbacks=bool(options.get("graph_callbacks", True)), + ) + + +def _close_everything() -> None: + """Close every span still open at teardown, leaves before agents.""" + with _STATE.lock: + roots = {info.root for info in _STATE.runs.values() if info.root} + for root in roots: + _close_open_leaves(root) + _STATE.tracker.close_open_agents(outcome="cancelled") + + +adapter = _Adapter() diff --git a/sdk/python/failproofai_sdk/integrations/llama_index.py b/sdk/python/failproofai_sdk/integrations/llama_index.py new file mode 100644 index 000000000..ef6bf5ecb --- /dev/null +++ b/sdk/python/failproofai_sdk/integrations/llama_index.py @@ -0,0 +1,1524 @@ +"""LlamaIndex adapter — written against llama-index-core 0.14.23 (2026-07-29). + + import failproofai_sdk + from llama_index.core.agent.workflow import FunctionAgent + + failproofai_sdk.instrument("llama_index") + await FunctionAgent(name="researcher", tools=[...], llm=llm).run("...") + +Everything is registered on the **root dispatcher** (`get_dispatcher()` with no +argument). Child dispatchers propagate upward, so one handler pair on the root +sees every span and every event in the process — no per-object wiring, no call +site changes. + +What most tutorials (and several shipping vendors) get wrong here +----------------------------------------------------------------- +Each of these was verified against the installed package, not recalled: + +1. ``llama_index.core.instrumentation`` is a **shim** over the separately + released ``llama-index-instrumentation`` distribution. Import through the + ``llama_index.core`` path anyway — it is the stable name. + +2. **The classic agent events are dead.** ``AgentRunStepStartEvent``, + ``AgentChatWithStepStartEvent`` and ``AgentToolCallEvent`` still *import*, + but nothing has emitted them since the 0.13.0 agent rewrite, so an adapter + built on them records nothing and raises nothing. Agent structure now lives + in the **workflow** stream, and we reach the typed objects through the span + handler: a workflow step span carries its input event in + ``bound_args.arguments["ev"]`` and hands back its output event as the span + ``result``. That is where ``AgentInput`` / ``AgentSetup`` / ``AgentOutput`` / + ``ToolCall`` / ``ToolCallResult`` actually are. + +3. **The dispatcher swallows handler exceptions** with a bare + ``except BaseException: pass`` **and no logging** (verified in + ``llama_index_instrumentation.dispatcher``). A bug in a handler is therefore + completely invisible. Every entry point below is wrapped in ``_core.safe``, + whose whole job is to log the thing the dispatcher would have eaten. + +4. **Never read ``event.model_dict["model"]``.** PR #22130 (shipped in 0.14.23) + replaced ``to_dict()`` with ``to_payload()`` and the ``"model"`` key is gone; + reading it yields ``None`` silently. Traceloop and MLflow are broken on this + today. We read ``instance.metadata.model_name`` off the LLM span instead, and + only fall back to ``model_dict.get("model_name")``. + +5. ``new_span`` takes **``parent_span_id``** while ``span_enter`` takes + ``parent_id``. Getting that wrong gives a flat trace with no error. + +6. Teardown needs **in-place slice assignment** — ``add_span_handler`` does + ``self.span_handlers += [h]``, so a plain ``=`` rebinds a pydantic field and + other handlers can be lost. + +Mapping +------- +============================ ========================================== +LlamaIndex Failproof AI +============================ ========================================== +``Workflow.run`` root span session + ``agent_start``/``agent_end`` +nested ``Workflow.run`` span nested ``agent_start``/``agent_end`` +``AgentWorkflow`` handoff nested ``agent_start``/``agent_end`` per + ``current_agent_name`` (see ``_sub_agent``) +workflow step span ``hook_triggered``/``hook_completed`` + (``trigger_event="workflow_step"``) +``SpanCancelledEvent`` ``outcome="cancelled"`` on the run or the step +``LLMChatStart/EndEvent`` ``model_request``/``model_response`` + (``request_id=event.span_id``) +``FunctionTool.call`` span ``tool_use``/``tool_result`` +``RetrievalStart/EndEvent`` ``tool_use``/``tool_result``, output summarized +embeddings nothing, unless ``embeddings=True`` +``WaitingForEvent`` drop ``human_wait``+``agent_pause``, then + ``agent_resume``+``human_input`` on retry +============================ ========================================== + +``agent_id`` is the ``FunctionAgent.name`` when there is one and the workflow +class name otherwise — never a span id. It is a ``LowCardinality`` column and +the primary dashboard facet; a uuid in it poisons that facet permanently. + +``AgentWorkflow`` needs one more step to keep that promise. It does **not** run +its agents as nested workflows — there is a single ``AgentWorkflow.run`` span +and the agents are steps inside it — so read off the span tree alone a two-agent +crew lands as one ``agent_id="AgentWorkflow"`` and the handoff is invisible. The +runtime does say who holds the turn, on every ``AgentInput``/``AgentSetup``/ +``AgentOutput`` a step is invoked with: ``current_agent_name``. Each distinct +name therefore opens a nested agent under the workflow and a handoff closes the +previous one, which is what puts ``researcher`` and ``analyst`` in the facet +rather than in a payload extra nobody can group by. + +Token fidelity is genuinely lower on LlamaIndex than on the other frameworks +----------------------------------------------------------------------------- +There is no standard usage field. We try ``response.raw["usage"]``, then +``raw["usage_metadata"]``, then ``response.additional_kwargs``, calling +``model_dump()`` first when ``raw`` is a pydantic model. The top-level +``input_tokens``/``output_tokens`` are set **only** when a key we recognise is +present; the raw dict always ships as ``usage`` so the server and the dashboard +can both fall back to it. A model integration that names its counters something +new will show a populated ``usage`` and blank token columns — that is the +honest outcome, and much better than a confident wrong number. + +**Streaming has no usage at all, and that is the default path.** +``FunctionAgent`` — the agent api LlamaIndex documents — calls +``astream_chat``, and llama-index-llms-openai does not send +``stream_options={"include_usage": True}``, so the provider never emits the +usage chunk and ``LLMChatEndEvent.response.raw`` has no ``usage`` key to find. +Verified against llama-index-core 0.14.23 by spying on the dispatcher directly: +every ``LLMChatEndEvent`` in a ``FunctionAgent`` run arrives with usage absent. +Nothing in this adapter can recover a number the framework never received. The +user-side fix is one argument, and it works:: + + OpenAI(model=..., additional_kwargs={"stream_options": {"include_usage": True}}) + +Non-streaming calls (``llm.chat`` / ``llm.achat``) extract usage correctly with +no extra configuration. + +Known gap: human-in-the-loop is only visible when the wait happens **inside a +tool**. ``ctx.wait_for_event`` in a plain workflow step is caught by the runtime +before it reaches the dispatcher, so that step simply exits with ``None`` and +re-runs later; there is no signal to key a pause on. The FunctionAgent pattern +(the one LlamaIndex documents) waits inside a tool and is captured. +""" + +from __future__ import annotations + +import re +import threading +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, Callable + +from failproofai_sdk.integrations import _compat, _core + +FRAMEWORK = "llama_index" +DIST = "llama-index-core" +EXTRA = "llamaindex" + +# 0.14.23 is a CAPABILITY floor, not a guess: it is the release where +# `to_payload()` replaced `to_dict()` (PR #22130) and where the workflow event +# stream carries the typed agent events this adapter reads. Below it, model +# names and agent structure both go missing. +MIN_VERSION = "0.14.23" +BELOW_VERSION = "0.15" + +# "{ClassName}.{method}-{uuid4}" — the dispatcher's span id format. +_SPAN_ID = re.compile( + r"^(?P<cls>[^.]+)\.(?P<method>.+)" + r"-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" +) + +# LLM methods worth an event. `_`-prefixed methods (`_prepare_chat_with_tools`) +# are framework plumbing and are ignored everywhere. +_LLM_METHODS = frozenset( + { + "chat", + "achat", + "stream_chat", + "astream_chat", + "complete", + "acomplete", + "stream_complete", + "astream_complete", + "predict", + "apredict", + } +) + +_SUMMARY_LIMIT = 512 +_MAX_SPANS = 20_000 +_MAX_NODES_IN_SUMMARY = 5 + +# Token key aliases, widest first. LlamaIndex normalises nothing, so this is the +# union of what the popular model integrations actually put in `raw`. +_INPUT_TOKEN_KEYS = ( + "prompt_tokens", + "input_tokens", + "inputTokens", + "prompt_token_count", + "promptTokenCount", +) +_OUTPUT_TOKEN_KEYS = ( + "completion_tokens", + "output_tokens", + "outputTokens", + "candidates_token_count", + "candidatesTokenCount", +) + + +# --------------------------------------------------------------------------- +# Helpers — no framework import in any of these +# --------------------------------------------------------------------------- + +def _span_parts(span_id: str) -> tuple[str, str]: + """`("FunctionAgent", "run")` from a dispatcher span id. Never raises.""" + match = _SPAN_ID.match(span_id or "") + if match is None: + text = str(span_id or "") + head = text.rsplit("-", 5)[0] if "-" in text else text + cls, _, method = head.partition(".") + return cls, method or head + return match.group("cls"), match.group("method") + + +def _summarize(value: Any, limit: int = _SUMMARY_LIMIT) -> str | None: + """A short, human-readable rendering. `None` in, `None` out.""" + if value is None: + return None + try: + text = str(value) + except Exception: + text = repr(value) + if not text: + # Several workflow events override `__str__` to return the response + # text, which is empty on a pure tool-call turn — and a free-form + # `Event(prefix="ok?")` keeps its payload in `_data`, so both `str()` + # and `repr()` render it as `InputRequiredEvent()`. An empty string in + # the dashboard reads as "we captured nothing". + try: + data = getattr(value, "_data", None) + if isinstance(data, dict) and data: + text = f"{type(value).__name__}({data})" + else: + text = repr(value) + except Exception: + text = type(value).__name__ + return _core.truncate(text, limit) + + +def _as_dict(value: Any) -> dict | None: + """A dict view of a pydantic model, a dict, or nothing.""" + if isinstance(value, dict): + return value + dump = getattr(value, "model_dump", None) + if callable(dump): + try: + dumped = dump() + except Exception: + return None + return dumped if isinstance(dumped, dict) else None + return None + + +def _first_int(source: dict, keys: tuple[str, ...]) -> int | None: + for key in keys: + value = source.get(key) + if isinstance(value, bool): + continue + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + # Bedrock hands back list-valued counts on some models. + if isinstance(value, (list, tuple)) and len(value) == 1: + inner = value[0] + if isinstance(inner, int) and not isinstance(inner, bool): + return inner + return None + + +def extract_usage(response: Any) -> tuple[dict | None, int | None, int | None]: + """`(usage_dict, input_tokens, output_tokens)` from a ChatResponse. + + Deliberately conservative: the token ints are returned **only** when a key + we recognise is present. Everything found ships as `usage` regardless, so a + model whose counters we cannot name still reports something the server's + summary and the dashboard can fall back to. + """ + if response is None: + return None, None, None + raw = _as_dict(getattr(response, "raw", None)) or {} + additional = getattr(response, "additional_kwargs", None) + additional = additional if isinstance(additional, dict) else {} + + usage: dict | None = None + for candidate in (raw.get("usage"), raw.get("usage_metadata"), additional.get("usage")): + as_dict = _as_dict(candidate) + if as_dict: + usage = as_dict + break + if usage is None and any(k in additional for k in _INPUT_TOKEN_KEYS + _OUTPUT_TOKEN_KEYS): + usage = { + k: v for k, v in additional.items() if k in _INPUT_TOKEN_KEYS + _OUTPUT_TOKEN_KEYS + } + if not usage: + return None, None, None + return usage, _first_int(usage, _INPUT_TOKEN_KEYS), _first_int(usage, _OUTPUT_TOKEN_KEYS) + + +def _messages(items: Any) -> list[dict] | None: + """ChatMessages -> the list-of-dicts `model_request(messages=...)` wants.""" + if not isinstance(items, (list, tuple)): + return None + out: list[dict] = [] + for item in items: + role = getattr(item, "role", None) + out.append( + { + "role": getattr(role, "value", None) or str(role or "user"), + "content": _summarize(getattr(item, "content", None) or ""), + } + ) + return out or None + + +def summarize_nodes(nodes: Any) -> dict: + """A retrieval result small enough to store. + + Retrieved documents are the largest strings in the process and the payload + is not a promoted column, so querying it means `JSONExtract` over the whole + blob. We keep the count, the scores and a prefix of the top few. + """ + items = list(nodes) if isinstance(nodes, (list, tuple)) else [] + top = [] + for node in items[:_MAX_NODES_IN_SUMMARY]: + inner = getattr(node, "node", node) + text = None + getter = getattr(inner, "get_content", None) + if callable(getter): + try: + text = getter() + except Exception: + text = None + if text is None: + text = getattr(inner, "text", None) + top.append( + { + "id": _summarize(getattr(inner, "node_id", None), 128), + "score": getattr(node, "score", None), + "text": _core.truncate(str(text or ""), 200), + } + ) + return {"num_nodes": len(items), "top": top} + + +def _error_text(exc: BaseException) -> str: + return f"{type(exc).__name__}: {exc}" + + +def _is_waiting(exc: BaseException | None) -> bool: + """True for the runtime's `WaitingForEvent` — a PAUSE, never an error. + + Name-based on purpose. `WaitingForEvent` lives in + `workflows.runtime.types.results`, is not re-exported from + `workflows.errors`, and has moved before; an `isinstance` against an import + that quietly failed would go always-False and turn every human-in-the-loop + pause into a red error event, which is exactly the failure this adapter + exists to avoid. + """ + if exc is None: + return False + return any(cls.__name__ == "WaitingForEvent" for cls in type(exc).__mro__) + + +def _is_cancellation(exc: BaseException | None) -> bool: + if exc is None: + return False + names = {cls.__name__ for cls in type(exc).__mro__} + return bool(names & {"CancelledError", "GeneratorExit"}) + + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class _Span: + span_id: str + parent_id: str | None + run_id: str | None # the enclosing agent span, or None if we never saw one + kind: str # agent | step | tool | model | retrieval | embedding | other + cls: str # the class half of the span id + name: str # the method half + started: float + + +@dataclass +class _Leaf: + """An emitted opener with no closer yet. Invariant 4 lives here.""" + + kind: str # tool | model | retrieval | embedding + span_id: str + parent_id: str | None + name: str + call_id: str + started: float + model: str | None = None + + +@dataclass +class _Run: + span_id: str + agent_id: str + open_leaves: dict[str, _Leaf] = field(default_factory=dict) + pauses: dict[str, str] = field(default_factory=dict) # waiter_id -> pause_id + used_tool_ids: dict[str, int] = field(default_factory=dict) + errors: int = 0 + # The `AgentWorkflow` sub-agent currently holding the turn, if any. See + # `_sub_agent`. Only ever set on a workflow run's own `_Run`; a sub-agent's + # `_Run` never opens a sub-agent of its own. + sub_key: str | None = None + sub_name: str | None = None + sub_seq: int = 0 + + +class _State: + """Everything the two handlers share. One lock, no contextvars. + + A span's start and its end are separate dispatcher calls that may land on + different tasks, so `ContextVar.reset(token)` is unusable here (it raises + across tasks as well as threads). Identity therefore comes from + `_core.RunTracker`, which passes `session_id=`/`agent_id=` explicitly. + """ + + def __init__(self, **options: Any) -> None: + self.embeddings = bool(options.get("embeddings", False)) + self.steps = bool(options.get("steps", True)) + self.capture_messages = bool(options.get("capture_messages", True)) + self.stale_after = float(options.get("stale_after", 600.0)) + self.reaper_interval = float(options.get("reaper_interval", 30.0)) + + self.tracker = _core.RunTracker( + FRAMEWORK, + base_fields=_core.framework_fields(FRAMEWORK, DIST), + ) + self._lock = threading.RLock() + self._spans: dict[str, _Span] = {} + self._runs: dict[str, _Run] = {} + # Per-instance, NOT class attributes: two _State objects (an + # install/uninstall cycle, or a test) must not share a span table. + self._step_inputs: dict[str, Any] = {} + self._model_names: dict[str, str] = {} + # span_id -> the run that owns its open leaf. A parked streaming span + # has already EXITED (and been forgotten from `_spans`) by the time its + # LLMChatEndEvent arrives, so the leaf cannot be found by walking the + # span tree — without this, every streaming model_response would be + # deferred to teardown and report the whole run as its duration. + self._leaf_run: dict[str, str] = {} + # Span ids the runtime told us were CANCELLED. `SpanCancelledEvent` is + # dispatched immediately before the matching `span_exit`, which the + # runtime deliberately performs with `result=None` and no error ("exit + # the span cleanly so it shows as OK rather than ERROR in traces" — + # workflows/runtime/types/step_function.py). Without this mark a + # user-pressed stop button is indistinguishable from a completed run, + # and every cancellation is reported as a success. + self._cancelled: set[str] = set() + self._stop = threading.Event() + self._reaper: threading.Thread | None = None + + # -- bookkeeping ------------------------------------------------------ + + def _remember(self, span: _Span) -> None: + while len(self._spans) >= _MAX_SPANS: + self._spans.pop(next(iter(self._spans)), None) + self._spans[span.span_id] = span + + def _run_of(self, span_id: str | None) -> _Run | None: + if span_id is None: + return None + span = self._spans.get(span_id) + if span is None or span.run_id is None: + return None + return self._runs.get(span.run_id) + + def _tool_call_id(self, run: _Run | None, raw_id: str | None, span_id: str) -> str: + """The framework's own tool id, kept verbatim where it is unambiguous. + + Passing the framework id through unchanged is what makes our events line + up with the customer's provider logs. But a human-in-the-loop tool is + re-run with the *same* `tool_id` after the human answers, and two pairs + sharing a `tool_call_id` in one session would pair wrongly, so a repeat + gets a `#n` suffix rather than a collision. + """ + if not raw_id: + return span_id + if run is None: + return raw_id + seen = run.used_tool_ids.get(raw_id, 0) + run.used_tool_ids[raw_id] = seen + 1 + return raw_id if seen == 0 else f"{raw_id}#{seen}" + + # -- spans ------------------------------------------------------------ + + def span_enter( + self, + span_id: str, + bound_args: Any, + instance: Any, + parent_span_id: str | None, + tags: dict | None, + ) -> None: + cls_name, method = _span_parts(span_id) + arguments = getattr(bound_args, "arguments", None) or {} + kind = self._classify(method, instance, parent_span_id) + + with self._lock: + parent = self._spans.get(parent_span_id) if parent_span_id else None + run_id = span_id if kind == "agent" else (parent.run_id if parent else None) + # An `AgentWorkflow` step belongs to the sub-agent holding the turn, + # not to the workflow. Resolving that BEFORE the span is remembered + # is what makes everything underneath it — the step's own hook pair, + # its tool calls, its LLM calls — resolve to the sub-agent too, + # because they all reach identity through this span's parent chain. + parent_key = parent_span_id + if kind == "step": + sub = self._sub_agent(parent, arguments) + if sub is not None: + parent_key = sub + run_id = sub + # Link every span, including the ones we emit nothing for: a chain + # of "other" spans between a leaf and its agent must not break + # identity resolution. + self.tracker.link(span_id, parent_key) + self._remember( + _Span( + span_id=span_id, + parent_id=parent_key, + run_id=run_id, + kind=kind, + cls=cls_name, + name=method, + started=time.monotonic(), + ) + ) + if kind == "agent": + self._start_agent(span_id, parent_span_id, instance, cls_name, arguments) + elif kind == "step": + self._start_step(span_id, parent_key, method, arguments) + elif kind == "tool": + self._start_tool(span_id, parent_span_id, instance, arguments) + elif kind == "model": + # The model leaf opens on LLMChatStartEvent, not here — the + # event is what carries the messages. All we need from the span + # is the model NAME, which the event no longer has (see the + # `to_payload()` note at the top). + pass + + def span_exit(self, span_id: str, bound_args: Any, instance: Any, result: Any) -> None: + with self._lock: + span = self._spans.pop(span_id, None) + cancelled = span_id in self._cancelled + self._cancelled.discard(span_id) + if span is None: + return + if span.kind == "agent": + self._end_agent( + span, + result=result, + outcome="cancelled" if cancelled else "success", + ) + elif span.kind == "step": + self._end_step(span, result=result, cancelled=cancelled) + elif span.kind == "tool": + self._close_leaf(span, output=result) + # A streaming LLM span exits the moment the generator is created, + # long before the stream is consumed. Its model leaf stays parked + # until LLMChatEndEvent, teardown, or the reaper. + + def span_drop(self, span_id: str, bound_args: Any, instance: Any, err: BaseException | None) -> None: + with self._lock: + span = self._spans.pop(span_id, None) + self._cancelled.discard(span_id) + if span is None: + return + if _is_waiting(err): + self._pause(span, err) + return + cancelled = _is_cancellation(err) + if span.kind == "agent": + self._end_agent( + span, + result=None, + outcome="cancelled" if cancelled else "failed", + error=None if cancelled else err, + ) + elif span.kind == "step": + self._end_step(span, result=None, error=None if cancelled else err) + else: + self._close_leaf(span, output=None, error=None if cancelled else err) + + def _classify(self, method: str, instance: Any, parent_span_id: str | None) -> str: + if method.startswith("_"): + return "other" + bases = _bases() + workflow = bases.get("Workflow") + if workflow is not None and isinstance(instance, workflow) and method == "run": + # Root or nested: a compiled sub-workflow is a nested agent, which + # is what the framework itself calls it. + return "agent" + if parent_span_id is None and instance is not None: + # Any other top-level instrumented call — `query_engine.query()`, + # a bare `llm.chat()` — opens the session and becomes its root + # agent, named after its class. The alternative is emitting leaves + # with no open agent above them, and the dashboard answers that by + # synthesizing a root span that stays `ongoing` forever. + return "agent" + if instance is None: + # A workflow step: the runtime wraps the step function, so there is + # no instance, and its parent is the workflow run span. Checking the + # parent (rather than the `llamaindex.step.*` tags) is what keeps + # this precise — those tags are inherited by every child span, + # including the LLM call inside the step. + parent = self._spans.get(parent_span_id) if parent_span_id else None + if parent is not None and parent.kind == "agent": + return "step" + return "other" + for kind, key in ( + ("tool", "BaseTool"), + ("model", "BaseLLM"), + ("retrieval", "BaseRetriever"), + ("embedding", "BaseEmbedding"), + ): + base = bases.get(key) + if base is not None and isinstance(instance, base): + if kind == "model" and method not in _LLM_METHODS: + return "other" + return kind + return "other" + + # -- agents ----------------------------------------------------------- + + def _start_agent( + self, + span_id: str, + parent_span_id: str | None, + instance: Any, + cls_name: str, + arguments: dict, + ) -> None: + # `FunctionAgent.name` when there is one, the class name otherwise. + # Never the span id: `agent_id` is a LowCardinality column and the + # global dashboard facet. + raw_name = getattr(instance, "name", None) + label = raw_name if isinstance(raw_name, str) and raw_name else cls_name + start_event = arguments.get("start_event") + goal = getattr(start_event, "user_msg", None) or _summarize(start_event) + + identity = self.tracker.start_agent( + span_id, + agent_id=label, + parent_key=parent_span_id, + goal=goal, + **_core.fw_fields( + span_id=span_id, + workflow=cls_name, + agent_name=raw_name if isinstance(raw_name, str) else None, + ), + ) + # `start_agent` always returns a real `agent_id` (it normalizes and falls + # back to the default); the `| None` is only on the shared `Identity`. + self._runs[span_id] = _Run( + span_id=span_id, agent_id=identity.agent_id or _core.DEFAULT_AGENT_ID + ) + + def _end_agent( + self, + span: _Span, + *, + result: Any, + outcome: str, + error: BaseException | None = None, + ) -> None: + run = self._runs.pop(span.span_id, None) + if run is not None: + # Inner-first: a sub-agent still holding the turn is closed (and its + # error count folded into ours) before we decide whether this run + # owns the failure, or it would outlive the workflow that opened it + # and render `ongoing` forever. + self._close_sub_agent(run, outcome=outcome) + # Invariant 4: `agent_end` force-closes open pauses but NOT open + # tools or models. A run that dies holding one leaves the session + # `ongoing` forever. + self._close_all_leaves(run, reason="run_ended") + if error is not None and run.errors == 0: + # Nothing below us reported this, so the run itself owns it. + # If a leaf already did, a second event would double-count on + # `sessionSummary.errorCount`. + self.tracker.emit( + "error", + span.span_id, + parent_key=span.parent_id, + error_type=type(error).__name__, + message=str(error) or type(error).__name__, + **_core.fw_fields(span_id=span.span_id), + ) + elif error is not None: + self.tracker.emit( + "error", + span.span_id, + parent_key=span.parent_id, + error_type=type(error).__name__, + message=str(error) or type(error).__name__, + ) + summary = _summarize(getattr(result, "result", None) if result is not None else None) + if summary is None and error is not None: + # `summary` is a promoted column and `agent_end` is where the + # dashboard reads a run's outcome. Without this a failed run says + # only "failed": the reason lives on the failing step's + # `hook_completed`, which is payload-only, and is gone entirely when + # `steps=False`. The sibling LangChain adapter already does this. + summary = _error_text(error) + self.tracker.end_agent( + span.span_id, + outcome=outcome, + summary=summary, + **_core.fw_fields(span_id=span.span_id), + ) + + # -- AgentWorkflow sub-agents ------------------------------------------- + + def _sub_agent(self, parent: _Span | None, arguments: dict) -> str | None: + """The nested agent an `AgentWorkflow` step belongs to, or `None`. + + `AgentWorkflow` does NOT run its `FunctionAgent`s as nested workflows — + there is one `AgentWorkflow.run` span and the agents are steps inside + it. Read off the span tree alone, a handoff is therefore invisible: a + two-agent crew lands as one `agent_id="AgentWorkflow"` and the names the + user actually facets by (`researcher`, `analyst`) never reach the + column. The framework does tell us, on every `AgentInput`/`AgentSetup`/ + `AgentOutput` the steps are invoked with: `current_agent_name`. + + So each distinct `current_agent_name` opens a nested agent under the + workflow, and a handoff closes the previous one. The name is **sticky**: + `ToolCall` carries no `current_agent_name`, so a `call_tool` step keeps + whichever agent asked for the tool, which is the correct attribution. + + A standalone `FunctionAgent.run` runs those same steps with its own name + in `current_agent_name`, which is already this run's `agent_id` — hence + the `name != root.agent_id` guard, without which every single-agent run + would nest an agent inside an identically-named agent. + """ + if parent is None or parent.kind != "agent" or parent.run_id is None: + return None + root = self._runs.get(parent.run_id) + if root is None: + return None + name = getattr(arguments.get("ev"), "current_agent_name", None) + if isinstance(name, str) and name and name != root.agent_id and name != root.sub_name: + self._close_sub_agent(root) + root.sub_seq += 1 + # Keyed per turn, not per name: an A -> B -> A handoff must not + # reuse the key of the A we already ended. + key = f"{root.span_id}#sub{root.sub_seq}" + identity = self.tracker.start_agent( + key, + agent_id=name, + parent_key=root.span_id, + **_core.fw_fields( + span_id=root.span_id, agent_name=name, workflow=root.agent_id + ), + ) + self._runs[key] = _Run(span_id=key, agent_id=identity.agent_id or name) + root.sub_key, root.sub_name = key, name + return root.sub_key + + def _close_sub_agent(self, root: _Run, *, outcome: str = "success") -> None: + key, root.sub_key, root.sub_name = root.sub_key, None, None + if key is None: + return + sub = self._runs.pop(key, None) + if sub is not None: + self._close_all_leaves(sub, reason="agent_switch") + # The workflow still has to know something below it failed, or + # `_end_agent` would emit a second `error` event for a failure a + # sub-agent's leaf already reported. + root.errors += sub.errors + self.tracker.end_agent( + key, outcome=outcome, **_core.fw_fields(span_id=root.span_id) + ) + + # -- steps ------------------------------------------------------------ + + def _start_step(self, span_id: str, parent_span_id: str | None, method: str, arguments: dict) -> None: + if not self.steps: + return + incoming = arguments.get("ev") + self.tracker.emit( + "hook_triggered", + span_id, + parent_key=parent_span_id, + hook_name=method, + hook_id=span_id, + trigger_event="workflow_step", + input=_summarize(incoming), + **_core.fw_fields( + step=method, + input_event=type(incoming).__name__ if incoming is not None else None, + agent_name=getattr(incoming, "current_agent_name", None), + ), + ) + + def _end_step( + self, + span: _Span, + *, + result: Any, + error: BaseException | None = None, + cancelled: bool = False, + ) -> None: + if not self.steps: + return + run = self._runs.get(span.run_id) if span.run_id else None + if error is not None and run is not None: + run.errors += 1 + if error is not None: + outcome = "failed" + elif cancelled: + # A cancelled step exits with `result=None` and no error, so without + # the mark it reports `success` with an empty output. + outcome = "cancelled" + else: + outcome = "success" + self.tracker.emit( + "hook_completed", + span.span_id, + parent_key=span.parent_id, + hook_name=span.name, + hook_id=span.span_id, + outcome=outcome, + output=_summarize(result), + error=_error_text(error) if error is not None else None, + **_core.fw_fields( + step=span.name, + output_event=type(result).__name__ if result is not None else None, + agent_name=getattr(result, "current_agent_name", None), + ), + ) + + # -- tools ------------------------------------------------------------ + + def _start_tool(self, span_id: str, parent_span_id: str | None, instance: Any, arguments: dict) -> None: + run = self._run_of(span_id) + metadata = getattr(instance, "metadata", None) + tool_name = getattr(metadata, "name", None) or type(instance).__name__ + + # The enclosing `call_tool` step carries the typed `ToolCall`, which is + # where the LLM's own tool id lives. Reusing it keeps our events lined + # up with the provider's. + raw_id = None + parent = self._spans.get(parent_span_id) if parent_span_id else None + if parent is not None and parent.kind == "step": + raw_id = getattr(self._step_input(parent_span_id), "tool_id", None) + call_id = self._tool_call_id(run, raw_id, span_id) + + # A resumed run announces itself by re-entering the tool that paused, + # so `agent_resume`/`human_input` go out BEFORE this attempt's + # `tool_use` — that keeps the paused window (which the graph measures + # from `agent_pause` to `agent_resume`) free of the retry. + if run is not None and run.pauses: + self._resume(run, span_id, parent_span_id) + + kwargs = arguments.get("kwargs") + self._open_leaf( + run, + _Leaf( + kind="tool", + span_id=span_id, + parent_id=parent_span_id, + name=tool_name, + call_id=call_id, + started=time.monotonic(), + ), + ) + self.tracker.emit( + "tool_use", + span_id, + parent_key=parent_span_id, + tool_name=tool_name, + tool_call_id=call_id, + input=kwargs if isinstance(kwargs, dict) else None, + **_core.fw_fields(span_id=span_id, tool_id=raw_id), + ) + + def _step_input(self, span_id: str | None) -> Any: + """The typed workflow event a step was invoked with. + + This is the whole point of reading the span handler rather than the + (dead) agent events: `bound_args.arguments["ev"]` on a step span is the + real `ToolCall` / `AgentInput` / `AgentOutput` object. + """ + return self._step_inputs.get(span_id) if span_id else None + + # -- leaves ----------------------------------------------------------- + + def _open_leaf(self, run: _Run | None, leaf: _Leaf) -> None: + if run is not None: + run.open_leaves[leaf.span_id] = leaf + self._leaf_run[leaf.span_id] = run.span_id + + def _take_leaf(self, span_id: str) -> tuple[_Run | None, _Leaf | None]: + """Detach an open leaf by span id, wherever its run is.""" + run = self._runs.get(self._leaf_run.pop(span_id, "") or "") + if run is None: + return None, None + return run, run.open_leaves.pop(span_id, None) + + def _close_leaf( + self, + span: _Span, + *, + output: Any, + error: BaseException | None = None, + ) -> None: + run, leaf = self._take_leaf(span.span_id) + if leaf is None: + return + if error is not None and run is not None: + run.errors += 1 + self._emit_leaf_close( + leaf, + output=output, + error=_error_text(error) if error is not None else None, + ) + + def _emit_leaf_close( + self, + leaf: _Leaf, + *, + output: Any, + error: str | None, + reason: str | None = None, + ) -> None: + if leaf.kind == "model": + usage, input_tokens, output_tokens = extract_usage(output) + message = getattr(output, "message", None) + content = getattr(message, "content", None) + if content is None: + content = getattr(output, "text", None) + self.tracker.emit( + "model_response", + leaf.span_id, + parent_key=leaf.parent_id, + model=leaf.model, + request_id=leaf.call_id, + role=getattr(getattr(message, "role", None), "value", None), + content=_summarize(content), + input_tokens=input_tokens, + output_tokens=output_tokens, + usage=usage, + error=error, + # Invariant 3: ALWAYS an int. `durationOf` prefers the closing + # event's value over end-start, which is what keeps model + # durations right even when the dashboard's FIFO pairing + # brackets the wrong pair. A float silently NULLs the column. + duration_ms=_core.ms(time.monotonic() - leaf.started), + **_core.fw_fields(span_id=leaf.span_id, closed_by=reason), + ) + return + # tool / retrieval / embedding all close as a tool_result. `duration_ms` + # is auto-computed from the pending `tool_call_id` and is REJECTED if we + # pass it, so it is deliberately absent here. + payload: Any + if leaf.kind == "retrieval": + payload = summarize_nodes(output) if error is None else None + else: + payload = _summarize(getattr(output, "content", None) or output) + self.tracker.emit( + "tool_result", + leaf.span_id, + parent_key=leaf.parent_id, + tool_name=leaf.name, + tool_call_id=leaf.call_id, + output=payload, + error=error, + **_core.fw_fields(span_id=leaf.span_id, closed_by=reason), + ) + + def _close_all_leaves(self, run: _Run, *, reason: str) -> None: + for leaf in list(run.open_leaves.values()): + run.open_leaves.pop(leaf.span_id, None) + self._leaf_run.pop(leaf.span_id, None) + self._emit_leaf_close(leaf, output=None, error=None, reason=reason) + + # -- human in the loop ------------------------------------------------- + + def _pause(self, span: _Span, err: BaseException | None) -> None: + """A `WaitingForEvent` drop: the run is waiting on a human. + + Both pairs are emitted, in this order, because neither alone is enough: + only `agent_pause`/`agent_resume` feeds the graph's paused time, and + only `human_wait`/`human_input` carries the prompt and the pending-human + badge. + """ + run = self._runs.get(span.run_id) if span.run_id else None + waiter = getattr(err, "add", None) + waiter_id = getattr(waiter, "waiter_id", None) or uuid.uuid4().hex + prompt = _summarize(getattr(waiter, "waiter_event", None)) + pause_id = f"{waiter_id}:{uuid.uuid4().hex[:8]}" + + # The tool that paused will be re-run from scratch when the human + # answers, so close its leaf now rather than leaving it open forever. + # This is honest: LlamaIndex really does call the tool twice. + if run is not None: + self._leaf_run.pop(span.span_id, None) + leaf = run.open_leaves.pop(span.span_id, None) + if leaf is not None: + self._emit_leaf_close(leaf, output=None, error=None, reason="human_wait") + run.pauses[pause_id] = waiter_id + + self.tracker.emit( + "human_wait", + span.span_id, + parent_key=span.parent_id, + input_id=pause_id, + prompt=prompt, + reason="workflow is waiting for a human response", + **_core.fw_fields(waiter_id=waiter_id, span_id=span.span_id), + ) + self.tracker.emit( + "agent_pause", + span.span_id, + parent_key=span.parent_id, + pause_id=pause_id, + reason="human_input", + **_core.fw_fields(waiter_id=waiter_id), + ) + + def _resume(self, run: _Run, span_id: str, parent_id: str | None) -> None: + for pause_id, waiter_id in list(run.pauses.items()): + run.pauses.pop(pause_id, None) + self.tracker.emit( + "agent_resume", + span_id, + parent_key=parent_id, + pause_id=pause_id, + reason="human_input", + **_core.fw_fields(waiter_id=waiter_id), + ) + # `response` is left unset: the human's answer arrives as a + # `HumanResponseEvent` sent straight into the workflow context, + # which never reaches the dispatcher. It surfaces on the paired + # `tool_result` instead. + self.tracker.emit( + "human_input", + span_id, + parent_key=parent_id, + input_id=pause_id, + **_core.fw_fields(waiter_id=waiter_id), + ) + + # -- dispatcher events ------------------------------------------------- + + def model_start(self, span_id: str, messages: Any, model_dict: Any, prompt: Any = None) -> None: + with self._lock: + span = self._spans.get(span_id) + run = self._run_of(span_id) + parent_id = span.parent_id if span is not None else None + model = self._model_name(span_id, model_dict) + self._open_leaf( + run, + _Leaf( + kind="model", + span_id=span_id, + parent_id=parent_id, + name=model or "llm", + call_id=span_id, + started=time.monotonic(), + model=model, + ), + ) + self.tracker.emit( + "model_request", + span_id, + parent_key=parent_id, + model=model, + # `request_id` is the LLM span id. It is what pairs the two + # model events in the dashboard's detail panel; no SDK set it + # before this work, so nothing was pairing. + request_id=span_id, + messages=_messages(messages) if self.capture_messages else None, + system=_summarize(prompt) if self.capture_messages else None, + **_core.fw_fields(span_id=span_id), + ) + + def model_end(self, span_id: str, response: Any, error: str | None = None) -> None: + with self._lock: + run, leaf = self._take_leaf(span_id) + if leaf is None: + return + if error is not None and run is not None: + run.errors += 1 + self._emit_leaf_close(leaf, output=response, error=error) + + def retrieval_start(self, span_id: str, query: Any) -> None: + with self._lock: + span = self._spans.get(span_id) + run = self._run_of(span_id) + parent_id = span.parent_id if span is not None else None + name = span.cls if span is not None else "retriever" + self._open_leaf( + run, + _Leaf( + kind="retrieval", + span_id=span_id, + parent_id=parent_id, + name=name, + call_id=span_id, + started=time.monotonic(), + ), + ) + self.tracker.emit( + "tool_use", + span_id, + parent_key=parent_id, + tool_name=name, + tool_call_id=span_id, + input={"query": _summarize(query)}, + **_core.fw_fields(span_id=span_id, kind="retrieval"), + ) + + def retrieval_end(self, span_id: str, nodes: Any) -> None: + with self._lock: + _run, leaf = self._take_leaf(span_id) + if leaf is None: + return + self._emit_leaf_close(leaf, output=nodes, error=None) + + def cancel(self, span_id: str | None) -> None: + """`SpanCancelledEvent` — remember it for the `span_exit` right behind it.""" + if span_id is None: + return + with self._lock: + while len(self._cancelled) >= _MAX_SPANS: + self._cancelled.discard(next(iter(self._cancelled))) + self._cancelled.add(span_id) + + def exception(self, span_id: str | None, exc: Any) -> None: + """`ExceptionEvent` — close whatever leaf that span owns. + + No standalone `error` event: the span that owns the failure reports it, + and the enclosing agent reports it once more only if nothing below it + did. + """ + if span_id is None: + return + with self._lock: + run, leaf = self._take_leaf(span_id) + if leaf is None: + return + if run is not None: + run.errors += 1 + text = _error_text(exc) if isinstance(exc, BaseException) else str(exc) + self._emit_leaf_close(leaf, output=None, error=text) + + def _model_name(self, span_id: str, model_dict: Any) -> str | None: + """`instance.metadata.model_name`, captured when the span opened. + + NOT `model_dict["model"]`: `to_payload()` replaced `to_dict()` in + 0.14.23 and that key no longer exists, so reading it returns None + silently — the bug Traceloop and MLflow are shipping today. + """ + name = self._model_names.get(span_id) + if name: + return name + if isinstance(model_dict, dict): + candidate = model_dict.get("model_name") + if isinstance(candidate, str) and candidate: + return candidate + return None + + # -- reaper ------------------------------------------------------------ + + def sweep(self) -> int: + """Close leaves nobody is going to close. Returns how many. + + A streaming response that is never consumed produces an + `LLMChatStartEvent` with no end, and its span has already exited. Left + alone that is an open `model_request` and a session the dashboard shows + as `ongoing` forever. + """ + closed = 0 + cutoff = time.monotonic() - self.stale_after + with self._lock: + for run in list(self._runs.values()): + for leaf in list(run.open_leaves.values()): + if leaf.started > cutoff: + continue + run.open_leaves.pop(leaf.span_id, None) + self._leaf_run.pop(leaf.span_id, None) + self._emit_leaf_close(leaf, output=None, error=None, reason="stale") + closed += 1 + return closed + + def start_reaper(self) -> None: + if self.reaper_interval <= 0 or self._reaper is not None: + return + thread = threading.Thread( + target=self._reap_loop, name="failproofai_sdk-llamaindex-reaper", daemon=True + ) + self._reaper = thread + thread.start() + + def _reap_loop(self) -> None: + while not self._stop.wait(self.reaper_interval): + _core.call_safely(self.sweep, (), {}, "llama_index.reaper") + + def shutdown(self) -> None: + """Close everything still open, then stop the reaper. Never raises.""" + self._stop.set() + with self._lock: + for run in list(self._runs.values()): + self._close_all_leaves(run, reason="uninstrument") + # Newest first, so a sub-agent closes before the workflow that + # opened it rather than after it. + for span_id in reversed(list(self._runs)): + self.tracker.end_agent(span_id, outcome="cancelled") + self._runs.clear() + self._spans.clear() + self._leaf_run.clear() + self._cancelled.clear() + self._step_inputs.clear() + self._model_names.clear() + self.tracker.reset() + thread = self._reaper + self._reaper = None + if thread is not None and thread.is_alive(): + thread.join(timeout=1.0) + + +# --------------------------------------------------------------------------- +# Framework base classes, imported once and only when asked +# --------------------------------------------------------------------------- + +_BASES: dict[str, Any] = {} + + +def _bases() -> dict[str, Any]: + """The isinstance targets used to classify a span. + + `isinstance` rather than a class-name string: a rename lands as a hard + ImportError at `instrument()` time instead of a classifier that quietly + stops matching. Each import is individually optional, because a partial + LlamaIndex install should cost one span kind, not the adapter. + """ + if _BASES: + return _BASES + targets = { + "Workflow": ("llama_index.core.workflow", "Workflow"), + "BaseTool": ("llama_index.core.tools.types", "BaseTool"), + "BaseLLM": ("llama_index.core.base.llms.base", "BaseLLM"), + "BaseRetriever": ("llama_index.core.base.base_retriever", "BaseRetriever"), + "BaseEmbedding": ("llama_index.core.base.embeddings.base", "BaseEmbedding"), + } + import importlib + + for key, (module_name, attribute) in targets.items(): + try: + _BASES[key] = getattr(importlib.import_module(module_name), attribute) + except Exception: + _compat.warn( + f"failproofai_sdk: llama_index could not resolve {module_name}.{attribute}; " + f"spans of that kind will be recorded as untyped.", + key=f"llama_index:base:{key}", + ) + _BASES[key] = None + return _BASES + + +# --------------------------------------------------------------------------- +# Handlers +# --------------------------------------------------------------------------- + +# The dispatcher dispatches on the concrete event class, so these names ARE the +# API. `test_llama_index.py` asserts every one of them still exists in the +# framework's event modules — a rename would otherwise leave this table looking +# perfectly healthy while recording nothing. +MODEL_START_EVENTS = ("LLMChatStartEvent", "LLMCompletionStartEvent") +MODEL_END_EVENTS = ("LLMChatEndEvent", "LLMCompletionEndEvent", "StreamChatEndEvent") +MODEL_ERROR_EVENTS = ("StreamChatErrorEvent",) +RETRIEVAL_START_EVENTS = ("RetrievalStartEvent",) +RETRIEVAL_END_EVENTS = ("RetrievalEndEvent",) +EMBEDDING_START_EVENTS = ("EmbeddingStartEvent",) +EMBEDDING_END_EVENTS = ("EmbeddingEndEvent",) +EXCEPTION_EVENTS = ("ExceptionEvent",) +# NOT part of `_HANDLED_EVENTS`: this one is dispatched by the workflows runtime +# (`workflows.runtime.types.step_function`), not from +# `llama_index.core.instrumentation.events.*` like every name above it, so the +# drift test that walks those modules cannot cover it. +CANCEL_EVENTS = ("SpanCancelledEvent",) + +_HANDLED_EVENTS = ( + MODEL_START_EVENTS + + MODEL_END_EVENTS + + MODEL_ERROR_EVENTS + + RETRIEVAL_START_EVENTS + + RETRIEVAL_END_EVENTS + + EMBEDDING_START_EVENTS + + EMBEDDING_END_EVENTS + + EXCEPTION_EVENTS +) + +_CLASSES: dict[str, Any] = {} + + +def handler_classes() -> tuple[Any, Any]: + """`(FailproofAIEventHandler, FailproofAISpanHandler)`, built on first use. + + They subclass framework base classes, so they cannot exist at module import + time — and `import failproofai_sdk` must stay free of LlamaIndex. Memoised so + repeated install/uninstall cycles do not rebuild pydantic models. + """ + if _CLASSES: + return _CLASSES["event"], _CLASSES["span"] + + from llama_index_instrumentation.event_handlers.base import BaseEventHandler + from llama_index_instrumentation.span_handlers.base import BaseSpanHandler + from pydantic import PrivateAttr + + class FailproofAIEventHandler(BaseEventHandler): + """Model, retrieval and embedding events. + + `handle` is wrapped in `_core.safe` because the dispatcher's own + `except BaseException: pass` has NO logging: without this, a bug here is + undetectable in production. + """ + + _state: Any = PrivateAttr(default=None) + + def __init__(self, state: Any = None, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._state = state + + @classmethod + def class_name(cls) -> str: + return "FailproofAIEventHandler" + + @_core.safe + def handle(self, event: Any, **kwargs: Any) -> None: + state = self._state + if state is None: + return + name = type(event).__name__ + span_id = getattr(event, "span_id", None) + if span_id is None: + return + if name in MODEL_START_EVENTS: + state.model_start( + span_id, + getattr(event, "messages", None), + getattr(event, "model_dict", None), + prompt=getattr(event, "prompt", None), + ) + elif name in MODEL_END_EVENTS: + state.model_end(span_id, getattr(event, "response", None)) + elif name in MODEL_ERROR_EVENTS: + state.model_end( + span_id, None, error=_summarize(getattr(event, "exception", None)) or name + ) + elif name in RETRIEVAL_START_EVENTS: + state.retrieval_start(span_id, getattr(event, "str_or_query_bundle", None)) + elif name in RETRIEVAL_END_EVENTS: + state.retrieval_end(span_id, getattr(event, "nodes", None)) + elif state.embeddings and name in EMBEDDING_START_EVENTS: + state.retrieval_start(span_id, "embedding") + elif state.embeddings and name in EMBEDDING_END_EVENTS: + state.retrieval_end(span_id, getattr(event, "embeddings", None)) + elif name in EXCEPTION_EVENTS: + state.exception(span_id, getattr(event, "exception", None)) + elif name in CANCEL_EVENTS: + state.cancel(span_id) + + class FailproofAISpanHandler(BaseSpanHandler): + """The span tree: agents, workflow steps, tools, retrievers. + + Note `new_span` takes **`parent_span_id`** while the dispatcher's + `span_enter` takes `parent_id`. Declaring the wrong one here gives a + flat trace and no error at all. + + Every override returns a truthy value so the base class's `span_exit` + can `del self.open_spans[id_]` — returning `None` from + `prepare_to_exit_span` deliberately LEAKS the entry (that is how the + framework parks streaming spans), and returning it from `new_span` would + make the matching `del` raise. + """ + + _state: Any = PrivateAttr(default=None) + + def __init__(self, state: Any = None, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._state = state + + @classmethod + def class_name(cls) -> str: + return "FailproofAISpanHandler" + + @_core.safe + def new_span( + self, + id_: str, + bound_args: Any, + instance: Any = None, + parent_span_id: str | None = None, + tags: dict | None = None, + **kwargs: Any, + ) -> str: + state = self._state + if state is not None: + arguments = getattr(bound_args, "arguments", None) or {} + if "ev" in arguments: + state._step_inputs[id_] = arguments["ev"] + metadata = getattr(instance, "metadata", None) + model_name = getattr(metadata, "model_name", None) + if isinstance(model_name, str) and model_name: + state._model_names[id_] = model_name + state.span_enter(id_, bound_args, instance, parent_span_id, tags) + return id_ + + @_core.safe + def prepare_to_exit_span( + self, + id_: str, + bound_args: Any, + instance: Any = None, + result: Any = None, + **kwargs: Any, + ) -> str | None: + state = self._state + if state is not None: + state.span_exit(id_, bound_args, instance, result) + state._step_inputs.pop(id_, None) + state._model_names.pop(id_, None) + # Truthy tells the base class to `del self.open_spans[id_]`, which + # raises if `new_span` never stored it (it returns None when + # `safe()` swallowed an exception). Returning None here parks the + # span instead — the framework's own mechanism, not a leak. + return id_ if id_ in self.open_spans else None + + @_core.safe + def prepare_to_drop_span( + self, + id_: str, + bound_args: Any, + instance: Any = None, + err: BaseException | None = None, + **kwargs: Any, + ) -> str | None: + state = self._state + if state is not None: + state.span_drop(id_, bound_args, instance, err) + state._step_inputs.pop(id_, None) + state._model_names.pop(id_, None) + return id_ if id_ in self.open_spans else None + + _CLASSES["event"] = FailproofAIEventHandler + _CLASSES["span"] = FailproofAISpanHandler + return FailproofAIEventHandler, FailproofAISpanHandler + + +# --------------------------------------------------------------------------- +# The adapter +# --------------------------------------------------------------------------- + +class _LlamaIndexAdapter: + """Registered as `llama_index`; see `failproofai_sdk.integrations.__init__`.""" + + name = FRAMEWORK + module = "llama_index" + + def __init__(self) -> None: + self.state: _State | None = None + self._handlers: tuple[Any, Any] | None = None + + def install(self, **options: Any) -> None: + instrumentation = _compat.require_module( + "llama_index.core.instrumentation", dist=DIST, extra=EXTRA + ) + _compat.check_version( + FRAMEWORK, + DIST, + minimum=MIN_VERSION, + below=BELOW_VERSION, + reason="0.14.23 replaced to_dict() with to_payload() and is the first " + "release whose workflow spans carry the typed agent events", + ) + if not _compat.probe( + FRAMEWORK, "get_dispatcher", lambda: instrumentation.get_dispatcher + ): + return + + state = _State(**options) + event_cls, span_cls = handler_classes() + event_handler = event_cls(state=state) + span_handler = span_cls(state=state) + + # The ROOT dispatcher. Child dispatchers propagate upward, so one + # registration here sees the whole process. + dispatcher = instrumentation.get_dispatcher() + dispatcher.add_event_handler(event_handler) + dispatcher.add_span_handler(span_handler) + + self.state = state + self._handlers = (event_handler, span_handler) + state.start_reaper() + + def uninstall(self) -> None: + handlers = self._handlers + self._handlers = None + state = self.state + self.state = None + if handlers is not None: + try: + import llama_index.core.instrumentation as instrumentation + + dispatcher = instrumentation.get_dispatcher() + ours = set(id(handler) for handler in handlers) + # IN-PLACE slice assignment. `add_span_handler` does + # `self.span_handlers += [h]`, so a plain `=` rebinds the + # pydantic field and can drop handlers added by someone else. + dispatcher.event_handlers[:] = [ + handler for handler in dispatcher.event_handlers if id(handler) not in ours + ] + dispatcher.span_handlers[:] = [ + handler for handler in dispatcher.span_handlers if id(handler) not in ours + ] + except Exception: + _core.logger.warning( + "failproofai_sdk: could not detach the llama_index handlers", exc_info=True + ) + if state is not None: + # Close every open leaf and agent before we stop listening: a run + # abandoned mid-flight would otherwise render `ongoing` forever. + _core.call_safely(state.shutdown, (), {}, "llama_index.shutdown") + + +adapter = _LlamaIndexAdapter() + +install: Callable[..., None] = adapter.install +uninstall: Callable[..., None] = adapter.uninstall diff --git a/sdk/python/failproofai_sdk/integrations/pydantic_ai.py b/sdk/python/failproofai_sdk/integrations/pydantic_ai.py new file mode 100644 index 000000000..a54eeb402 --- /dev/null +++ b/sdk/python/failproofai_sdk/integrations/pydantic_ai.py @@ -0,0 +1,947 @@ +"""Failproof AI adapter for Pydantic AI, built on `AbstractCapability`. + +Written against **pydantic-ai-slim 2.20.0** (2026-07-29), read out of the +installed package rather than from memory. Every v1 tutorial is wrong for this +version: `Agent(instrument=...)` was **removed in 2.0.0b1**, and +`opentelemetry-api` became a core, non-optional dependency of pydantic-ai-slim. + +Why the capability protocol and not an OTel span processor +---------------------------------------------------------- +`pydantic_ai.capabilities.AbstractCapability` is a full middleware protocol — +`wrap_run`, `wrap_model_request`, `wrap_tool_execute` and friends — and +Pydantic's own `Instrumentation` capability is just another consumer of it with +no privileged access. So `capabilities=[FailproofAI()]`: + +* hands us typed `RunContext` / `ModelResponse` / `RunUsage` objects instead of + stringly-typed span attributes; +* needs no OTel **SDK** on the user's machine (only the API package, which is + already a hard dependency); +* cannot double-count against the user's own tracing, because we are a sibling + of `Instrumentation` rather than a second exporter on the same spans. + +The OTel route would additionally have had to read **both** `gen_ai.usage.*` +(model spans) and `gen_ai.aggregated_usage.*` (run spans) — the latter is a +Pydantic extension that every existing vendor silently drops, which is how +agent-run token counts come out as zero elsewhere. We read `RunUsage` directly +and the question does not arise. + +Adapter shape +------------- +This is **Shape A** end to end: every capability hook is a wrapper whose start +and end sit in one frame, so the adapter can bind Failproof AI identity onto +contextvars for the duration of a run (`failproofai_sdk.session(...)`) and nested +`agent.run()` calls — an agent invoked from inside another agent's tool — nest +themselves with no bookkeeping. `RunTracker` still owns the id/parent/session +resolution and every emit, so `framework` / `framework_version` / +`integration_version` land on **every** event, including `agent_end`. + +One thing Shape A does **not** give you here, and `_open_runs` is the whole of +what this module keeps to cover it: a leaf hook can return *after* the run hook +that contains it. Cancellation is the reliable case — the graph awaits a +`gather` of tool tasks, so the run body unwinds the moment that future is +cancelled while each tool task's own `CancelledError` lands a loop iteration +later. `_close_spans` therefore closes whatever leaves are still open before +`agent_end`, and `_claim_span` / `_claim_run` make the late handler (and a +concurrent `uninstall()`) a no-op rather than a duplicate. + +How it installs +--------------- +There is no supported global capability default in 2.20: `Agent.instrument_all()` +sets `Agent._instrument_default`, which is consulted *only* for the built-in +`Instrumentation` capability, and the one real auto-injection list +(`pydantic_ai.agent._AUTO_INJECT_CAPABILITY_TYPES`) is a private module +constant. So `install()` wraps the public `Agent.__init__` and appends our +capability to its keyword-only `capabilities=` argument, saving the original +function object through `_core.Patcher` so `uninstall()` restores exactly that +object. + +Consequence, and it is worth knowing: an `Agent` **constructed while +instrumented keeps the capability object forever** — we cannot retro-remove +ourselves from an already-built agent. `uninstall()` therefore also flips a +module-level flag that makes every hook a straight pass-through, so an agent +built before `uninstrument()` stops recording rather than recording into a +half-dismantled adapter. By the same token, agents constructed *before* +`instrument()` are not instrumented; construct them after, or pass +`capabilities=[FailproofAI()]` yourself. + +What is deliberately not captured +--------------------------------- +* **Graph nodes** (`UserPromptNode`, `ModelRequestNode`, `CallToolsNode`) get no + events. They are Pydantic AI's own loop machinery, not user-authored steps — + unlike a LangGraph node, which is a `hook_*` pair because the user wrote it. + Everything a node does that is worth seeing is already covered by the model + and tool spans, and `wrap_node_run` would double the row count for nothing. + Not overriding it also keeps `AbstractCapability.has_wrap_node_run` False. +* **`wrap_run_event_stream`** is not overridden either, and that one is a + landmine: overriding it makes `agent.run()` switch itself into streaming mode. + Per-token events are forbidden anyway (a 500-token response would be 500 + stored rows against a 5-lane rail). +""" + +import dataclasses +import logging +import threading +import time +import traceback as _traceback +import uuid +from typing import Any + +import failproofai_sdk +from failproofai_sdk._scopes import _is_cancellation +from failproofai_sdk.integrations import _compat, _core +from failproofai_sdk.integrations._core import ( + RunTracker, + framework_fields, + fw_fields, + ms, + normalize_agent_id, + safe, +) + +logger = logging.getLogger("failproofai_sdk.integrations") + +NAME = "pydantic_ai" +MODULE = "pydantic_ai" +DIST = "pydantic-ai-slim" +EXTRA = "pydantic-ai" + +# 2.0 is a CAPABILITY floor, not a guess: it is the release that removed +# `Agent(instrument=...)` and introduced `AbstractCapability` — the entire +# surface this module is built on. The ceiling is deliberate: without one, a +# clean build after the next major shifts the hook names and this adapter stops +# recording while raising nothing at all. +MIN_VERSION = "2.0" +BELOW_VERSION = "3" +_VERSION_REASON = "2.0 removed Agent(instrument=...) and added the capabilities middleware protocol" + +# This module is only ever imported by `instrument("pydantic_ai")`, so a +# module-level framework import is fine here and nowhere else — `import failproofai_sdk` +# still pulls in zero third-party packages. `require_module` first so the failure +# is the tier-1 ImportError carrying the literal install command, rather than a +# bare `No module named 'pydantic_ai'`. +_compat.require_module("pydantic_ai.capabilities", dist=DIST, extra=EXTRA) + +from pydantic_ai.capabilities import ( # noqa: E402 (deliberately after the probe) + AbstractCapability, + CapabilityOrdering, +) + +# One tracker for the process. `base_fields` is what puts `framework` on every +# single event this adapter emits, agent_end and error included. +_tracker = RunTracker(NAME, base_fields=framework_fields(NAME, DIST)) + +# Flipped by install()/uninstall(). Agents built while instrumented keep the +# capability instance, so this flag — not the patch — is what actually stops the +# recording. Read on every hook, written only under the registry lock. +_enabled = False +_capability: "FailproofAI | None" = None +_patcher = _core.Patcher() + + +# --------------------------------------------------------------------------- +# Control flow that is not failure +# --------------------------------------------------------------------------- + +def _control_flow_types() -> tuple: + """Exceptions Pydantic AI raises to *steer* a run, not to report a failure. + + Resolved by name at import time and tolerant of every one of them being + absent: this list is exactly the kind of thing a minor release renames, and + a missing name must degrade to "treat it as an error" rather than to an + `AttributeError` inside the customer's run. + + `ModelRetry` / `ToolRetryError` / `ToolFailedError` are **not** here on + purpose. They mean an attempt genuinely failed and the model was asked to + try again, which is exactly what a tool span's `error` field is for; the run + itself still ends `success` if the retry works. + """ + import pydantic_ai.exceptions as exceptions + + found = [] + for name in ( + "SkipToolExecution", + "SkipToolValidation", + "SkipModelRequest", + "CallDeferred", + "ApprovalRequired", + ): + candidate = getattr(exceptions, name, None) + if isinstance(candidate, type) and issubclass(candidate, BaseException): + found.append(candidate) + return tuple(found) + + +_CONTROL_FLOW = _control_flow_types() + + +def _is_control_flow(exc: BaseException) -> bool: + return _is_cancellation(type(exc)) or isinstance(exc, _CONTROL_FLOW) + + +def _describe(exc: BaseException) -> str: + text = str(exc) + return f"{type(exc).__name__}: {text}" if text else type(exc).__name__ + + +_TRACEBACK_MARKER = "[older frames truncated]…\n" + + +def _format_traceback(exc: BaseException) -> str: + """The traceback, trimmed from the FRONT if it is too long. + + Every truncation in `_core` keeps the head, which is exactly wrong here: a + traceback's last line is the exception itself, and Pydantic AI's async graph + stack is comfortably longer than the 8KB field limit. Keeping the head would + ship 8KB of framework frames and drop the one line anybody reads. + """ + text = "".join(_traceback.format_exception(type(exc), exc, exc.__traceback__)) + if len(text) <= _core.FIELD_LIMIT: + return text + tail = text[-(_core.FIELD_LIMIT - len(_TRACEBACK_MARKER)):] + return _TRACEBACK_MARKER + tail + + +# --------------------------------------------------------------------------- +# Reading the framework's objects +# --------------------------------------------------------------------------- + +def _run_key(ctx: Any) -> Any: + """A stable per-run key. `run_id` is set for every real run; the fallback + only matters for a synthetic `RunContext` that is not backed by one.""" + run_id = getattr(ctx, "run_id", None) + return ("pydantic_ai.run", run_id if run_id else id(ctx)) + + +def _agent_name(ctx: Any) -> str: + """`agent_id` must stay low-cardinality and human readable. + + Pydantic AI infers `Agent.name` from the assigning call frame on the first + run, so this is normally the variable name (`weather_agent`). + `normalize_agent_id` turns anything id-shaped — or nothing at all — into + `main` and the real id rides in `fw_run_id`. + """ + agent = getattr(ctx, "agent", None) + return normalize_agent_id(getattr(agent, "name", None)) + + +def _text(value: Any) -> str | None: + if value is None: + return None + return value if isinstance(value, str) else str(value) + + +def _model_name(model: Any) -> str | None: + return getattr(model, "model_name", None) if model is not None else None + + +def _render_part(part: Any, capture_content: bool) -> dict: + out: dict[str, Any] = {"part_kind": getattr(part, "part_kind", None) or type(part).__name__} + tool_name = getattr(part, "tool_name", None) + if tool_name: + out["tool_name"] = tool_name + tool_call_id = getattr(part, "tool_call_id", None) + if tool_call_id: + out["tool_call_id"] = tool_call_id + if capture_content: + content = getattr(part, "content", None) + if content is None: + content = getattr(part, "args", None) + if content is not None: + out["content"] = content + return out + + +# Pydantic AI hands `wrap_model_request` the WHOLE conversation on every step, +# so shipping it verbatim makes a 30-step run quadratic in payload size — and +# these are prompts, i.e. the largest strings in the process. `truncate` caps +# each string, but nothing caps the count, so the tail is capped here. The head +# of a long conversation is the least interesting part of a model request. +_MESSAGE_LIMIT = 20 + + +def _render_messages(messages: Any, capture_content: bool) -> "tuple[list[dict] | None, int]": + """(rendered tail, number of older messages omitted).""" + if not messages: + return None, 0 + messages = list(messages) + omitted = max(len(messages) - _MESSAGE_LIMIT, 0) + return [ + { + "kind": getattr(message, "kind", None), + "parts": [_render_part(p, capture_content) for p in getattr(message, "parts", ()) or ()], + } + for message in messages[omitted:] + ], omitted + + +def _render_tools(params: Any, capture_content: bool) -> list[dict] | None: + tools: list[dict] = [] + for group in ("function_tools", "output_tools", "native_tools"): + for tool in getattr(params, group, None) or (): + entry: dict[str, Any] = { + "name": getattr(tool, "name", None) or type(tool).__name__, + "kind": group, + } + if capture_content: + description = getattr(tool, "description", None) + if description: + entry["description"] = description + tools.append(entry) + return tools or None + + +# `RunUsage` renamed `request_tokens`/`response_tokens` to +# `input_tokens`/`output_tokens` in 2.0. Reading the new names only is correct +# for the declared floor, and the anti-drift test asserts they still exist. +_USAGE_KEYS = ( + "input_tokens", + "output_tokens", + "total_tokens", + "cache_read_tokens", + "cache_write_tokens", + "requests", + "tool_calls", +) + + +def _usage_dict(usage: Any) -> dict | None: + """The normalized `usage` blob. Both `event_summary.rs` and + `sessionSummary.ts` fall back to it when the top-level ints are absent.""" + if usage is None: + return None + out = {} + for key in _USAGE_KEYS: + value = getattr(usage, key, None) + if isinstance(value, int) and not isinstance(value, bool) and value: + out[key] = value + return out or None + + +def _token(usage: Any, key: str) -> int | None: + value = getattr(usage, key, None) + return value if isinstance(value, int) and not isinstance(value, bool) else None + + +def _tool_output(result: Any) -> Any: + """What the tool actually produced, unwrapped and JSON-shaped. + + Two lossy renderings happen without this, and `tool_result.output` is the + single most-read field in a tool loop: + + * A tool may return `ToolReturn`, an **envelope** — `return_value` is what + goes back to the model, `content` is an extra user-prompt part and + `metadata` is deliberately never shown to the model at all. The envelope + has no JSON shape, so it rendered as + `ToolReturn(return_value={'answer': 42}, content=…, metadata=…)`: the + answer buried inside a repr, next to a field the model never saw. + * A tool returning a Pydantic model or a dataclass — the documented way to + return structured data — rendered as `Weather(city='Faro', celsius=21)` + rather than `{"city": "Faro", "celsius": 21}`, so nothing downstream can + read a field out of it. + + `_core.truncate` reprs an object with "no JSON shape", which is the right + default for an arbitrary object and wrong for these: they have one, and this + module is the only place that knows it. It is unwrapped with the object's + own `model_dump` / `dataclasses.asdict` rather than `pydantic_core`, because + this package declares no runtime dependencies and `tests/test_zero_dependencies.py` + reads the AST — a guarded import would still be an import. Anything else is + handed through untouched, and every failure falls back to the original value, + which `truncate` then reprs exactly as it does today. + """ + value = getattr(result, "return_value", result) if _is_tool_return(result) else result + if value is None or isinstance(value, (str, bytes, bool, int, float, list, tuple, dict)): + return value + if _is_base_model(value): + for mode in ("json", None): + try: + return value.model_dump(mode=mode) if mode else value.model_dump() + except Exception: + continue + return value + if dataclasses.is_dataclass(value) and not isinstance(value, type): + try: + return dataclasses.asdict(value) + except Exception: + return value + return value + + +def _is_tool_return(value: Any) -> bool: + return getattr(value, "kind", None) == "tool-return" and hasattr(value, "return_value") + + +def _is_base_model(value: Any) -> bool: + """Duck-typed: `model_dump` alone also matches a TypedDict helper or a mock.""" + return hasattr(value, "model_dump") and hasattr(value, "model_fields_set") + + +# --------------------------------------------------------------------------- +# Per-hook state +# --------------------------------------------------------------------------- + +class _RunState: + __slots__ = ("key", "scope", "spans", "closed") + + def __init__(self, key: Any) -> None: + self.key = key + self.scope: Any = None + # Leaf spans this run has opened and not yet closed, newest last. This + # exists because a leaf can outlive its own run — see `_close_spans`. + self.spans: dict[int, "_SpanState"] = {} + self.closed = False + + +class _SpanState: + __slots__ = ("key", "kind", "correlation_id", "started", "extra", "managed") + + def __init__( + self, key: Any, kind: str, correlation_id: str, extra: dict | None = None + ) -> None: + self.key = key + self.kind = kind + self.correlation_id = correlation_id + # perf_counter, not wall clock: a clock adjustment mid-run would + # otherwise produce a negative duration. + self.started = time.perf_counter() + self.extra = extra or {} + # True once this span is registered against a live run, i.e. once the + # run's teardown is able to close it instead of us. + self.managed = False + + def elapsed_ms(self) -> int: + return ms(time.perf_counter() - self.started) + + +# Runs whose `wrap_run` frame is still open, keyed by `_run_key`. The adapter is +# otherwise stateless — this table exists for one reason, and it is the whole of +# `_close_spans` below: a leaf can outlive the run that opened it. +_open_runs: "dict[Any, _RunState]" = {} +_runs_lock = threading.Lock() + +# Bounded for the same reason `RunTracker` is: a `wrap_run` coroutine that is +# garbage-collected before it resumes runs neither of its end branches, and an +# unbounded table of those is a leak in a long-lived server. FIFO — dicts keep +# insertion order. +_MAX_OPEN_RUNS = 10_000 + + +def _register_run(state: _RunState) -> None: + with _runs_lock: + while len(_open_runs) >= _MAX_OPEN_RUNS: + _open_runs.pop(next(iter(_open_runs)), None) + _open_runs[state.key] = state + + +def _claim_run(state: _RunState) -> bool: + """True for the first caller to close this run; False for every later one. + + Two things close a run — `wrap_run`'s own end, and `uninstall()` tearing + down mid-flight — and both must be able to go first. Without this, an + `uninstrument()` called while a run is in flight emitted `agent_end` + (`cancelled`) and the run then emitted a second `agent_end` (`success`) + against a span the dashboard had already closed. + """ + with _runs_lock: + _open_runs.pop(state.key, None) + if state.closed: + return False + state.closed = True + return True + + +def _register_span(span: _SpanState) -> None: + with _runs_lock: + run = _open_runs.get(span.key) + if run is not None: + run.spans[id(span)] = span + span.managed = True + + +def _claim_span(span: _SpanState) -> bool: + """True if this caller owns the span's closing event. + + False only when the run's teardown already emitted it. A span that was never + registered against a run (its `wrap_run` never opened one) is unmanaged and + always closes itself, so an adapter half-failure loses no leaf. + """ + if not span.managed: + return True + with _runs_lock: + run = _open_runs.get(span.key) + if run is None: + return False + return run.spans.pop(id(span), None) is not None + + +def _close_spans(state: _RunState, exc: "BaseException | None") -> None: + """Close every leaf this run opened and did not close, newest first. + + A leaf can outlive its own run, and on the cancellation path it reliably + does. `asyncio.wait_for` cancels the caller's task; the graph is awaiting + a `gather` of tool tasks, so the run body unwinds as soon as that future + is cancelled while each tool task's own `CancelledError` is delivered on + a later loop iteration. `wrap_run` therefore returns *before* + `wrap_tool_execute` does, and the same is true of `wrap_model_request` + when the cancellation lands inside the provider call. Measured against + pydantic-ai 2.32: a `wait_for` timeout produced `agent_end` at + `.565709` and the matching `tool_result` at `.566689`, and a timeout + during a model request put `model_response` after `agent_end` too — the + one thing every other emit in this file is careful never to do, because + the dashboard closes the agent span at `agent_end` and anything after it + is attributed to nothing. Worse, the ambient identity the late leaf + resolves through is unbound by then in some interleavings, and the event + is dropped outright: a `tool_use` with no `tool_result` at all. + + So the run closes them, `_claim_span` stops the real handler emitting a + duplicate when it finally unwinds, and `fw_incomplete` says the leaf did + not report its own outcome. + """ + with _runs_lock: + pending = list(state.spans.values()) + state.spans.clear() + if not pending: + return + # A cancellation is not a failure, exactly as on `agent_end`. + error = None if exc is None or _is_control_flow(exc) else _describe(exc) + for span in reversed(pending): + if span.kind == "tool": + _tracker.emit( + "tool_result", + None, + parent_key=span.key, + tool_name=span.extra.get("tool_name"), + tool_call_id=span.correlation_id, + error=error, + **fw_fields(incomplete=True), + ) + else: + _tracker.emit( + "model_response", + None, + parent_key=span.key, + model=span.extra.get("model"), + role="assistant", + request_id=span.correlation_id, + error=error, + duration_ms=span.elapsed_ms(), + **fw_fields(incomplete=True, streaming=span.extra.get("streaming")), + ) + + +def _close_open_runs() -> None: + """Close every run this adapter still has open, newest first. + + Teardown, not a hot path: `uninstall()` is the only caller. Each run's + leaves close before its `agent_end`, and `_claim_run` makes the run's own + `wrap_run` frame a no-op when it eventually unwinds. + """ + with _runs_lock: + states = list(_open_runs.values()) + for state in reversed(states): + _core.call_safely(_close_run, (state,), {}, f"{__name__}.uninstall") + + +def _close_run(state: _RunState) -> None: + if not _claim_run(state): + return + _close_spans(state, None) + _tracker.end_agent(state.key, outcome="cancelled") + + +# --------------------------------------------------------------------------- +# The capability +# --------------------------------------------------------------------------- + +class FailproofAI(AbstractCapability): + """Record a Pydantic AI agent run into Failproof AI. + + from failproofai_sdk.integrations.pydantic_ai import FailproofAI + agent = Agent("openai:gpt-5.6-sol", capabilities=[FailproofAI()]) + + or, for every agent constructed from now on:: + + failproofai_sdk.instrument("pydantic_ai") + + Event mapping: + + | Pydantic AI | Failproof AI | + |----------------------|-------------------------------------------| + | `wrap_run` | `agent_start` / `agent_end` (+ `error`) | + | `wrap_model_request` | `model_request` / `model_response` | + | `wrap_tool_execute` | `tool_use` / `tool_result` | + | graph nodes | nothing — see the module docstring | + + `session_id` is the run's `conversation_id` (a conversation spanning several + runs is one Failproof AI session, which is the point), unless a hand-written + `failproofai_sdk.session(...)` / `failproofai_sdk.agent(...)` is already open — that always + wins, so mixing the manual API with this adapter produces one tree, not two. + """ + + def __init__( + self, + *, + capture_content: bool = True, + session_id: str | None = None, + id: str | None = None, + ) -> None: + # `AbstractCapability` is a `@dataclass(init=False)`, so it contributes + # class-level defaults (`id`, `defer_loading`, …) and no `__init__` to + # chain to; assigning the ones we care about is the supported pattern. + self.capture_content = capture_content + self.session_id = session_id + self.id = id + + def get_ordering(self) -> CapabilityOrdering: + """Outermost, so our spans bracket every other capability's work. + + The built-in `Instrumentation` declares the same tier; ties break on the + user's list order, which is fine — we are not exchanging state with it. + """ + return CapabilityOrdering(position="outermost") + + # -- run -------------------------------------------------------------- + + async def wrap_run(self, ctx, *, handler): + if not _enabled: + return await handler() + state = self._begin_run(ctx) + try: + result = await handler() + except BaseException as exc: + # Note the structure: the framework's call sits in exactly one + # `try`, whose only job is to re-raise. Everything of ours is + # outside it and inside `safe()`, so nothing we do can change what + # the run returns or raises. + self._end_run(state, None, exc) + raise + self._end_run(state, result, None) + return result + + @safe + def _begin_run(self, ctx) -> _RunState: + key = _run_key(ctx) + ambient = failproofai_sdk.current() + # An already-bound session wins. Handing `start_agent` an explicit + # session_id would otherwise split a hand-written outer scope's run into + # a second session. + session_id = self.session_id or ( + None if ambient.session_id else (getattr(ctx, "conversation_id", None) or getattr(ctx, "run_id", None)) + ) + identity = _tracker.start_agent( + key, + agent_id=_agent_name(ctx), + session_id=session_id, + goal=_text(getattr(ctx, "prompt", None)) if self.capture_content else None, + **fw_fields( + run_id=getattr(ctx, "run_id", None), + conversation_id=getattr(ctx, "conversation_id", None), + model=_model_name(getattr(ctx, "model", None)), + metadata=getattr(ctx, "metadata", None), + ), + ) + state = _RunState(key) + _register_run(state) + try: + scope = failproofai_sdk.session(identity.session_id, agent_id=identity.agent_id) + scope.__enter__() + state.scope = scope + except Exception: # pragma: no cover - two contextvar sets cannot fail + # Deliberately not re-raised: the agent is already open, and the + # state we return is what closes it. A lost contextvar binding costs + # nesting for a nested run; a lost `agent_end` costs a session that + # renders `ongoing` forever. + logger.debug("failproofai_sdk: could not bind pydantic-ai run identity", exc_info=True) + return state + + @safe + def _end_run(self, state: "_RunState | None", result: Any, exc: "BaseException | None") -> None: + if state is None: + return + if not _claim_run(state): + # `uninstall()` tore this run down already and emitted its agent_end. + # A second one would close a span the dashboard has already closed. + if state.scope is not None: + state.scope.__exit__(None, None, None) + state.scope = None + return + try: + # Strictly before agent_end, for the same reason the `error` event + # below is: the dashboard closes the agent span at agent_end. + _core.call_safely(_close_spans, (state, exc), {}, f"{__name__}.close_spans") + usage = _usage_dict(getattr(result, "usage", None)) if result is not None else None + if exc is None: + outcome = "success" + summary = _text(getattr(result, "output", None)) if self.capture_content else None + elif _is_control_flow(exc): + # A cancellation is not an error: it must not pollute the Errors + # surface, so no `error` event and `outcome="cancelled"`. + outcome, summary = "cancelled", None + else: + outcome, summary = "failed", None + # Strictly BEFORE agent_end — the dashboard closes the agent span + # at agent_end and anything after it is attributed to nothing. + # This is the one place a standalone `error` event is right: the + # failure escaped the run, so no leaf span owns it. A tool or + # model failure that the loop recovered from is reported only on + # its own span and never reaches here. + _tracker.emit( + "error", + state.key, + error_type=type(exc).__name__, + message=str(exc), + traceback=_format_traceback(exc), + ) + # "failed", never "failure": the server counts only + # error|failed|timeout|rejected as a failure. + _tracker.end_agent(state.key, outcome=outcome, summary=summary, usage=usage) + finally: + if state.scope is not None: + state.scope.__exit__(None, None, None) + state.scope = None + + # -- model ------------------------------------------------------------ + + async def wrap_model_request(self, ctx, *, request_context, handler): + if not _enabled: + return await handler(request_context) + state = self._begin_model(ctx, request_context) + try: + response = await handler(request_context) + except BaseException as exc: + self._end_model(ctx, state, None, exc) + raise + self._end_model(ctx, state, response, None) + return response + + @safe + def _begin_model(self, ctx, request_context) -> _SpanState: + # `request_id` is generated here and carried onto the response. The + # dashboard pairs model events FIFO per agent_id when it is absent, which + # mis-pairs the moment two model calls overlap. + state = _SpanState(_run_key(ctx), "model", uuid.uuid4().hex) + model = getattr(request_context, "model", None) + state.extra["model"] = _model_name(model) + # Carried onto the response because that is the event `duration_ms` + # rides on — see the note there. + state.extra["streaming"] = getattr(request_context, "streaming", None) + _register_span(state) + messages, omitted = _render_messages( + getattr(request_context, "messages", None), self.capture_content + ) + _tracker.emit( + "model_request", + None, + parent_key=state.key, + model=state.extra["model"], + system=getattr(model, "system", None), + messages=messages, + tools=_render_tools( + getattr(request_context, "model_request_parameters", None), self.capture_content + ), + request_id=state.correlation_id, + **fw_fields( + run_step=getattr(ctx, "run_step", None), + streaming=getattr(request_context, "streaming", None), + model_id=getattr(request_context, "model_id", None), + messages_omitted=omitted or None, + ), + ) + return state + + @safe + def _end_model(self, ctx, state: "_SpanState | None", response: Any, exc: "BaseException | None") -> None: + if state is None or not _claim_span(state): + return + usage = getattr(response, "usage", None) if response is not None else None + error = None + if exc is not None and not _is_control_flow(exc): + error = _describe(exc) + _tracker.emit( + "model_response", + None, + parent_key=state.key, + model=_model_name(response) or state.extra.get("model"), + stop_reason=getattr(response, "finish_reason", None), + input_tokens=_token(usage, "input_tokens"), + output_tokens=_token(usage, "output_tokens"), + content=( + [_render_part(p, True) for p in getattr(response, "parts", ()) or ()] + if response is not None and self.capture_content + else None + ), + role="assistant", + request_id=state.correlation_id, + error=error, + # Always an int, always present. `duration_ms` is not guarded on + # model_response and the dashboard prefers the closing event's value + # over end-start, which is what keeps model durations honest even + # when FIFO pairing brackets the wrong pair. A float would silently + # NULL the column. + # + # On a STREAMED request this number is the whole `async with + # agent.run_stream(...)` block, consumer included, and not the model + # call: Pydantic AI hands `wrap_model_request`'s handler its + # `ModelResponse` only once the caller leaves that block, and + # `after_model_request` fires later still (measured: 818.7ms vs + # 317.6ms for the drain). There is no earlier hook — `stream_text` + # is the caller's own loop and `wrap_run_event_stream` cannot be + # overridden here without switching `agent.run()` into streaming + # mode. Measured against the gateway, 1.5s of `asyncio.sleep` in the + # consumer moved a 3294ms response to 4677ms. So the number cannot + # be made honest, only made *identifiable*: `fw_streaming` rides on + # the response as well as the request, so a latency percentile can + # exclude the rows where it is true rather than quietly averaging + # somebody's UI render time into the model's p95. + duration_ms=state.elapsed_ms(), + usage=_usage_dict(usage), + **fw_fields( + provider=getattr(response, "provider_name", None), + provider_response_id=getattr(response, "provider_response_id", None), + run_step=getattr(ctx, "run_step", None), + streaming=state.extra.get("streaming"), + ), + ) + + # -- tools ------------------------------------------------------------ + + async def wrap_tool_execute(self, ctx, *, call, tool_def, args, handler): + if not _enabled: + return await handler(args) + state = self._begin_tool(ctx, call, tool_def, args) + try: + result = await handler(args) + except BaseException as exc: + self._end_tool(state, None, exc) + raise + self._end_tool(state, result, None) + return result + + @safe + def _begin_tool(self, ctx, call, tool_def, args) -> _SpanState: + # The framework's own id, verbatim: it is what appears in the provider's + # logs, and inventing a synthetic one would destroy that correspondence. + # `duration_ms` on tool_result is auto-computed by the SDK from this key, + # so tool_use and tool_result must agree on it exactly. + tool_call_id = getattr(call, "tool_call_id", None) or uuid.uuid4().hex + tool_name = getattr(call, "tool_name", None) or getattr(tool_def, "name", None) or "tool" + state = _SpanState(_run_key(ctx), "tool", tool_call_id, {"tool_name": tool_name}) + _register_span(state) + _tracker.emit( + "tool_use", + None, + parent_key=state.key, + tool_name=tool_name, + tool_call_id=tool_call_id, + input=args if self.capture_content and isinstance(args, dict) else None, + **fw_fields( + tool_kind=getattr(tool_def, "kind", None), + run_step=getattr(ctx, "run_step", None), + ), + ) + return state + + @safe + def _end_tool(self, state: "_SpanState | None", result: Any, exc: "BaseException | None") -> None: + if state is None or not _claim_span(state): + return + error = None + if exc is not None and not _is_control_flow(exc): + error = _describe(exc) + # No `error` event here, ever. A tool failure the agent loop catches is + # not a run-level error, and one that escapes is reported exactly once, + # by `_end_run`. + _tracker.emit( + "tool_result", + None, + parent_key=state.key, + tool_name=state.extra.get("tool_name"), + tool_call_id=state.correlation_id, + output=_tool_output(result) if self.capture_content else None, + error=error, + ) + + +# --------------------------------------------------------------------------- +# install / uninstall +# --------------------------------------------------------------------------- + +def _inject(kwargs: dict) -> None: + capability = _capability + if capability is None: + return + existing = kwargs.get("capabilities") + items = list(existing) if existing else [] + if any(isinstance(item, FailproofAI) for item in items): + # Somebody passed `capabilities=[FailproofAI()]` explicitly. Theirs wins; + # two of us would double every event. + return + items.append(capability) + kwargs["capabilities"] = items + + +def _wrap_init(original): + """`Agent.__init__` with our capability appended to `capabilities=`. + + Not `_core.wrap_callable`: that shape deliberately cannot touch the wrapped + callable's arguments (its whole guarantee is that the user's call is + untouched), and injecting a capability is precisely an argument mutation. + The `Patcher` still owns the save/restore and the "somebody patched on top + of us" check, so the install discipline is unchanged. + """ + + def _failproofai_agent_init(self, *args, **kwargs): + _core.call_safely(_inject, (kwargs,), {}, f"{__name__}.Agent.__init__") + return original(self, *args, **kwargs) + + _failproofai_agent_init.__name__ = getattr(original, "__name__", "__init__") + _failproofai_agent_init.__qualname__ = getattr(original, "__qualname__", "Agent.__init__") + _failproofai_agent_init.__doc__ = getattr(original, "__doc__", None) + return _failproofai_agent_init + + +class _Adapter: + name = NAME + module = MODULE + + def install(self, **options: Any) -> None: + """Append `FailproofAI()` to every `Agent` constructed from now on. + + Unknown options are ignored rather than rejected: `failproofai_sdk.instrument()` + with no framework name fans the same `**options` out to every detected + adapter, so a keyword meant for LangChain must not take this one down. + """ + global _enabled, _capability + + _compat.check_version( + NAME, DIST, minimum=MIN_VERSION, below=BELOW_VERSION, reason=_VERSION_REASON + ) + + from pydantic_ai import Agent + + _capability = FailproofAI( + capture_content=bool(options.get("capture_content", True)), + session_id=options.get("session_id"), + ) + _patcher.patch(Agent, "__init__", _wrap_init(Agent.__init__)) + _enabled = True + + def uninstall(self) -> None: + global _enabled, _capability + + _enabled = False + _capability = None + _patcher.restore_all() + # Close every agent still open, so a session torn down mid-run does not + # render `ongoing` forever. Runs this adapter opened go through + # `_close_run` — leaves first, and `_claim_run` so the run's own + # `wrap_run` frame does not emit a second `agent_end` when it finally + # unwinds. `close_open_agents()` stays as the backstop for a key that + # reached the tracker without reaching `_open_runs`. + _close_open_runs() + _tracker.close_open_agents() + _tracker.reset() + + +adapter = _Adapter() +install = adapter.install +uninstall = adapter.uninstall diff --git a/sdk/python/failproofai_sdk/py.typed b/sdk/python/failproofai_sdk/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml new file mode 100644 index 000000000..72715a1a9 --- /dev/null +++ b/sdk/python/pyproject.toml @@ -0,0 +1,86 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "failproofai-sdk" +dynamic = ["version"] +requires-python = ">=3.10" +description = "Emit agent telemetry to FailproofAI Cloud — zero-dependency, stdlib only" +readme = "README.md" +license = { file = "LICENSE" } +authors = [{ name = "Failproof AI", email = "failproofai@exosphere.host" }] +keywords = ["agents", "observability", "ai", "monitoring", "failproofai", "telemetry", "sdk"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] + +# There is deliberately NO `dependencies` key. This SDK imports nothing outside the +# standard library, and that is a load-bearing property rather than a happy accident: +# it is installed into other people's agent processes, where any dependency of ours is +# a version constraint on their application. `tests/test_zero_dependencies.py` fails if +# a runtime dependency is ever declared or imported, and the CI job installs the built +# wheel with `--no-deps` to prove the claim against the artifact. + +# No [project.scripts] either — this is a library, not a command. The command is `fp`, +# which ships from ../../fp-cli. + +[project.urls] +Homepage = "https://befailproof.ai" +Documentation = "https://docs.befailproof.ai/agenteye/python-sdk" +Source = "https://github.com/FailproofAI/failproofai" + +[project.optional-dependencies] +# Test-only, and it stays that way — `dependencies` above is empty and +# `tests/test_zero_dependencies.py` fails if anything lands here without a reason. +# `tomli` is `tomllib`'s backport: 3.10 has no stdlib TOML parser, and that test +# reads this file to prove the promise, so on 3.10 it needs one rather than +# skipping the check on the oldest interpreter we advertise. +dev = ["pytest>=7", "tomli>=2; python_version<'3.11'", "pytest-asyncio>=1.3,<2"] + +# Convenience aliases that pull the FRAMEWORK in. The adapter code always ships +# in the base wheel and lazy-imports, so these gate nothing on our side — most +# users already have the framework and will never install an extra. +# +# Each floor is a CAPABILITY floor with a stated reason, not a guess, and each +# ceiling is deliberate: without one, a clean build a year from now pulls the +# next major, the callback API shifts, and the adapter stops receiving events +# while raising nothing. +# +# NEVER write `failproofai-sdk[...]` inside an extra, and there is deliberately +# no `[all]`: an extra that installs four agent frameworks at once is a resolver +# problem handed to somebody who wanted a telemetry library. +langchain = ["langchain-core>=1.4.7,<2"] # langgraph 1.2's own floor +langgraph = ["langgraph>=1.2,<2"] # first release with GraphCallbackHandler +crewai = [ + "crewai>=1.13,<2", # started_event_id + normalized usage + "onnxruntime>=1.14,<1.24; python_version < '3.11'", # 1.24+ dropped CPython 3.10 wheels +] +llamaindex = ["llama-index-core>=0.14.23,<0.15"] +pydantic-ai = ["pydantic-ai-slim>=2.0,<3"] # 2.0 removed Agent(instrument=...) + +[tool.setuptools.dynamic] +version = { attr = "failproofai_sdk._version.__version__" } + +[tool.setuptools.packages.find] +where = ["."] +include = ["failproofai_sdk*"] + +[tool.setuptools.package-data] +failproofai_sdk = ["py.typed"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +# `framework` marks a suite that needs a real agent framework installed. Those +# skip on a plain `pytest` run and are exercised in an environment that has the +# frameworks — registering it here is what keeps an unknown-mark warning from +# training everyone to ignore warnings. +markers = ["framework: needs a real agent framework installed"] diff --git a/sdk/python/skill/SKILL.md b/sdk/python/skill/SKILL.md new file mode 100644 index 000000000..07799728f --- /dev/null +++ b/sdk/python/skill/SKILL.md @@ -0,0 +1,382 @@ +--- +name: failproofai-sdk +description: |- + The way to make an AI agent report what it did to Failproof AI — planning what to record, writing the instrumentation, and proving the events land. Reach for it on vague phrasing too: "add observability to my agent", "why isn't my agent showing up?" + + Trigger when the user wants to: + • plan an integration — which points in their agent loop to record, and what the platform must see before sessions, errors, and evals work at all; + • write or fix instrumentation — add the `failproofai_sdk` Python SDK to an agent codebase, thread session/agent identity through it, emit tool, model, hook, or human events; + • verify it — confirm events are being written, or debug an integration that looks correct and produces nothing. + + Served by the `failproofai_sdk` Python SDK, inside the user's own agent. + + NOT for reading telemetry that already landed or operating a deployment (that's `fp-cli`), or building the evaluator service that scores runs (that's `agenteye-evaluator`). +--- + +# Failproof AI Python SDK + +The SDK records what your agent did, from inside your agent. You call it at points +you choose; it appends structured events to local `.jsonl` files. A separate +collector ships those files to the platform. + +``` +your agent calls failproofai_sdk.event.* + → SDK queues it in memory + → flush thread writes <base_dir>/events/event-<timestamp>.jsonl + → collector picks the file up and ships it + → visible as sessions / events / errors / evals +``` + +**The SDK's job ends at the file.** That boundary is the most useful thing to know +about it: everything up to the `.jsonl` is yours to get right and yours to verify, +and it is verifiable on a laptop with no server, no API key, and no network. + +The API is small — 15 event methods, all keyword-only. The hard parts are +**deciding where to call them** and **knowing which silences are bugs**, because +this SDK does not raise when you get it wrong. Sections 1-3 are the plan, 4 is the +code, 5-6 are the proof. + +## 1. Install it + +```bash +pip install failproofai-sdk # or: uv add failproofai-sdk +``` + +The distribution is `failproofai-sdk` and the import is `failproofai_sdk`. Public +PyPI, no token, no dependencies. + +**One command to never run: `pip install agenteye`.** That name belongs to a +stranded release of an old CLI — a different product that shipped under it before +moving to `fp-cli`. PyPI versions cannot be withdrawn, so the name still resolves +to that build forever. You get the CLI, `import failproofai_sdk` raises +`ModuleNotFoundError`, and on a codebase still using the pre-rename SDK (which +published under `agenteye` too) pip treats it as an upgrade and **removes the SDK**. + +> **Tell:** if a coding agent proposes `pip install agenteye` to install the SDK, +> this skill never loaded. Stop and re-read it. + +The CLI is a fine thing to want — it is what reads the telemetry back. Install it +separately, never with `pip` into your agent's environment: + +```bash +pipx install fp-cli # the command is `fp` +``` + +Confirm what you actually have before writing a line of instrumentation: + +```bash +python -c "import failproofai_sdk; print(failproofai_sdk.__version__)" +``` + +A version like `0.0.1b14` is the SDK. `ModuleNotFoundError` means it is not +installed — check `pip show agenteye`, which returning anything means the wrong +name was installed. `references/install.md` covers migrating an existing +`import agenteye` integration. + +## 2. Plan before you instrument + +Instrumentation lands in code that already exists and already works. Read it +first, then decide. Two questions settle most of the design, and only the user can +answer the first: + +> **What is one run of this agent?** That is your `session_id` — one value for the +> whole run, generated by you at the point the run starts. A chat turn, a job, a +> request, a workflow execution. If the agent handles concurrent runs, this must +> be per-run, not per-process. +> +> **What are the distinguishable actors in a run?** That is your `agent_id` — a +> stable *label*, not a unique id. `"planner"`, `"researcher"`, `"main"`. It is how +> the platform tells sub-agents apart, so reuse the same string across runs. + +Get these two named and agreed before writing code. They are the axes every +surface groups by, and changing them later splits the history: old runs keep the +old labels and the trends break. + +### The two events everything else hangs off + +Most of the catalog is optional and incremental. These two are not: + +| Event | Without it | +|---|---| +| `agent_start` | **The session does not exist.** No row on Sessions, no timeline, no evaluation — while every other event you emit still lands fine and shows up in the event stream. | +| `agent_end` | The run never closes, and it is not handed to the evaluator at the normal time. | + +That first row is the single most common integration failure, and it is +completely silent: a run emitting 500 tool calls and no `agent_start` produces a +busy event stream and **zero sessions**. Sessions are *defined* as "something that +emitted `agent_start`". So: + +**Emit `agent_start` at the top of the run and `agent_end` at every exit, and get +those two working end-to-end before you instrument anything else.** One event at +each end proves the whole path — install, identity, base dir, collector — with +almost no code to be wrong. Add tools, models, and hooks after that path is green. + +### Then map the rest onto the agent's shape + +Walk the agent loop and pick the points that exist in *this* codebase. Skip what +doesn't apply; there is no requirement to emit every type. + +| In the code | Emit | Buys you | +|---|---|---| +| every exit path of a run — success, exception, early return | `agent_start` / `agent_end` | the session itself | +| the tool dispatcher, both sides of the call | `tool_use` / `tool_result` | what ran, in what order, how long | +| the LLM client wrapper, both sides | `model_request` / `model_response` | model mix, token spend, stop reasons | +| your `except` blocks | `error` | the Errors surface | +| a policy/guard/middleware layer | `hook_triggered` / `hook_completed` | hook behaviour | +| an approval gate or human handoff | `human_wait` / `human_input`, `human_pause`, `human_interrupt` | where runs sit waiting on people | +| a run that suspends and resumes — waiting for a human, throttled, user-paused | `agent_pause` / `agent_resume` | a real "paused" state: the agent isn't ended, the resume isn't a new agent, and wait time is excluded from active work | + +If the codebase has one tool dispatcher and one LLM wrapper, you have two edit +sites for the bulk of the value. If tool calls are scattered inline across the +codebase, say so — a wrapper (§4) is worth more than 40 call sites. + +Full field-by-field catalog: `references/events.md`. + +## 3. The contract + +Work with these; none of them raise, so none of them show up in testing. + +- **There IS an ambient session, and it is the ergonomic path.** `session()`, + `agent()` and `tool_call()` bind identity on contextvars, so `session_id` and + `agent_id` are optional on all 15 event methods — omitted, they resolve from + the enclosing scope. `current()` reads it; `propagate(fn)` carries it into a + new thread, which contextvars do NOT do on their own. + + This section said the opposite until the scopes existed, and the reference + integration shipped a contextvars wrapper as markdown for customers to paste + into their own code. That is now in the package. + + Nothing bound and nothing passed raises `TypeError` naming the fix — never a + silent emit, because ingest skips an event with no session and answers `200`. + + Two more shapes raise, for the same reason: + + | You pass | Raises | Why it cannot be allowed through | + | --- | --- | --- | + | A non-`str` id | `TypeError` | Ingest skips the event and still answers `200` | + | `""` or `" "` | `ValueError` | Worse — ingest *accepts* it, and every event merges under one blank id | + +- **`configure()` is optional, and every call restates all of it.** It is + keyword-only with exactly three settings: + + | arg | default resolution | + |---|---| + | `base_dir` | `$AGENTEYE_HOME`, else `~/.failproofai/custom-agents` | + | `environment` | `$AGENTEYE_ENVIRONMENT`, else `"dev"` | + | `flush_interval` | `0.5` (seconds) | + + The default root moved here from `~/.agenteye`. `failproofaid` watches both, + so on a host running it nothing changes but the directory name, and batches + already in `~/.agenteye/events` still get collected. On a host running the + older `agenteye-collector` — which resolves `$AGENTEYE_HOME` or `~/.agenteye` + and nothing else — set `AGENTEYE_HOME=~/.agenteye`, or events pile up where + nothing reads with no error on either side. `AGENTEYE_SPOOL_TO_FAILPROOFAI` + is retired; it required a directory nothing created, so it never fired. + + Each call *sets all three* — omitted arguments are **reset to default + resolution**, not left alone. So a later `configure(flush_interval=1.0)` + silently moves your events back to the default directory and re-resolves the + environment. An explicit `configure(environment=...)` beats the env var; omit it + and the env var applies again. Call it **once**, at startup, before the first + event, passing every argument you care about. + +- **`environment` defaults to `"dev"`.** An unconfigured production agent reports + its runs as `dev` and they are invisible wherever the team filters on + `production`. Set it explicitly via `configure(environment=...)` or the + `AGENTEYE_ENVIRONMENT` env var. This is a favourite: everything works, in the + wrong bucket. + + **A comma raises `ValueError`.** `configure(environment="prod,eu")` is rejected + at the call site: ingest splits this field on commas to build filter facets, so + a comma would discard the whole event server-side with nothing said. + +- **Non-JSON payload leaves are stringified.** Events are serialized on a + background thread. Ordinary structured JSON retains its types; unsupported + leaves such as `datetime`, `UUID`, `Decimal`, `set`, `bytes`, or a Pydantic + model are converted with `str(value)` so one awkward tool result cannot stop + recording. Prefer plain JSON values when downstream queries need their + structure; use explicit custom serialization when a string would be ambiguous. + +- **Field *names* are unvalidated — but only the optional ones.** Every method + takes arbitrary `**fields` and stores them as-is, so a typo'd *optional* name + (`inpt=` for `input=`) is not an error, it is a new field, and nothing will tell + you. Typos in *required* names raise `TypeError` (they're real parameters), and + five reserved names — `timestamp`, `session_id`, `agent_id`, `type`, + `environment` — raise `ValueError`. + +- **`outcome="failed"`, not `"failure"`.** A run counts as failed only when + `outcome` (or `status`) is one of `error`, `failed`, `timeout`, `rejected` + (case-insensitive). `"failure"` is the natural antonym of the `"success"` in + every example — and it silently counts as *not a failure*. The run shows green. + +- **You own correlation, and ids are scoped per session AND per kind.** + Pending spans are keyed `tool:<session_id>:<tool_call_id>` and + `hook:<session_id>:<hook_id>`, so a `hook_completed(hook_id="x")` cannot pair + with a pending `tool_use(tool_call_id="x")`, and two concurrent sessions both + using `call_1` cannot cross-pair either. `input_id` and `pause_id` are scoped + the same way. + + What must still be unique is an id **within one session, for one kind**. The + pending map is a plain assignment, so emitting `tool_use(tool_call_id="call_1")` + twice in one session overwrites the first entry and the first `tool_result` + measures from the wrong start. Reusing your framework's id is always safe + (Anthropic and OpenAI ids are globally unique); a per-run counter is safe only + if you do not reset it inside a session. + + If you have read older guidance describing one flat, process-wide map shared + between tools and hooks: that was true, and is not any more. + +- **`duration_ms` is computed for you on four methods only** — `tool_result`, + `hook_completed`, `human_input`, `agent_resume` — from the matching earlier event. + Passing it to those four raises `ValueError`. Passing it to any of the other + eleven is **silently accepted as a custom field**. + +- **Events are fire-and-forget.** `event.*` queues in memory and returns; a daemon + thread writes every 0.5s, plus once at interpreter exit. A clean exit flushes. + A hard kill (`SIGKILL`, `os._exit`, a container OOM) drops whatever is queued, + silently. + + **`SIGTERM` deserves its own line, because it is not exotic — it is every + rolling deploy**, every `docker stop`, every Kubernetes eviction, and every + plain `kill`. CPython installs **no** handler for it: `signal.getsignal(SIGTERM)` + is `SIG_DFL`, the OS terminates the process where it stands, and **`atexit` + does not run**. Whatever is queued is gone — and what is in flight at shutdown + is disproportionately `agent_end`, so runs never close and never reach the + evaluator. The 0.5s flush interval is what bounds the loss, not the exit path. + + So if your process can receive `SIGTERM`, handle it — the SDK will not install + a handler in your process behind your back: + + import signal, sys, failproofai_sdk + + def _flush_and_exit(signum, frame): + failproofai_sdk._writer.flush_now() + sys.exit(128 + signum) + + signal.signal(signal.SIGTERM, _flush_and_exit) + + (`sys.exit` here rather than `os._exit`: it unwinds, so any `agent()` scope + still open emits its `agent_end` before the flush. That scope closes + `outcome="failed"` with an `error` naming `SystemExit`, because an evicted run + did not finish — which is the thing you want to be able to see.) `SIGKILL`, + `os._exit` and a container OOM cannot be handled by anything, and drop the + queue silently. + +## 4. Write it + +Threading `session_id` and `agent_id` through every call site by hand is the thing +that makes integrations ugly and abandoned. Don't. Bind identity once per run and +let the call sites read it. + +`references/frameworks.md` covers the four adapters. `references/integration.md` has the hand-written wrapper — one small +module, correct under `asyncio` and threads, adaptable to any codebase — plus +worked shapes for a tool dispatcher, an LLM client wrapper, and framework-specific +callback layers. Read it before writing your own; the naive version (a module +global, or a plain attribute) breaks the moment two runs overlap, and it breaks by +mixing two runs' events together rather than by failing. + +Match the codebase you're in. If it's async, the wrapper is async. If it already +has a request context or a trace id, bind to that instead of inventing one. + +## 5. Verify — watch the files + +**This is the whole point of the file boundary: you can prove the integration +without a server.** Run the agent and look. + +Resolve the spool the way the SDK does, rather than guessing at a path: + +```bash +python -c "import failproofai_sdk._resolver as r; print(r.get_base_dir() / 'events')" +``` + +With no environment variables set that prints `~/.failproofai/custom-agents/events`. +It is `$AGENTEYE_HOME/events` when that variable is set — which is what a host still +running the older `agenteye-collector` sets, to `~/.agenteye`. + +```bash +ls -la ~/.failproofai/custom-agents/events/ +``` + +You are looking for `event-<UTC timestamp>-<pid>-<seq>.jsonl` files — the pid and +sequence number are what keep two processes flushing in the same millisecond from +overwriting each other. Each line is one event. Read them with a JSON parser, not +`grep` — the exact spacing is not a contract, and a grep for `"type":"agent_start"` +returns nothing on a perfectly healthy integration: + +```bash +cat ~/.failproofai/custom-agents/events/*.jsonl | python -m json.tool --json-lines | head -20 +``` + +Then check, in this order — the first failure explains everything downstream: + +1. **Any files at all — or do they stop mid-run?** Look at stderr for + `Exception in thread failproofai-sdk-flush`. **This is the first thing to check and + the worst thing to miss**: one non-JSON-serializable value killed the writer, + and everything after it — including the at-exit flush — is gone (§3). The tell + is that events stop for *every* type at once, and nothing raised. If instead + there were never any files: did `import failproofai_sdk` succeed (§1)? Is the base dir + writable? Did the process die hard (`SIGKILL`, `docker stop`, an OOM) before a + flush? +2. **Is `agent_start` there, once per run?** No → you will see events on the + platform and no sessions, and you will spend an afternoon on it (§2). +3. **Sessions but no tool or model events?** Your emit path is dropping them + before the SDK ever sees them — nearly always because they're emitted from a + thread the identity never reached. See `references/integration.md` → *"Threads + will drop your events"*. The SDK is silent here; only your own wrapper can warn. +4. **Is `environment` what you expect?** It is `"dev"` unless you set it (§3). +5. **Is `outcome` on `agent_end` a word that counts?** `failed`/`error`/`timeout`/ + `rejected` — not `"failure"` (§3). Failed runs showing green is this, every + time. +6. **Run two overlapping runs.** Confirm two `session_id`s with **no events + crossing between them**. Do not check this with one run: a single run passes + even when identity is a module global, and mixing only appears once two runs + overlap — which is production, not your laptop (§4). +7. **Do `tool_use` and `tool_result` share a `tool_call_id`?** Unpaired means no + duration. Also confirm your ids are unique *process-wide* — a collision pairs + the wrong two events and reports a confident wrong duration (§3). + +A test-mode loop that costs nothing: + +```bash +export AGENTEYE_HOME=/tmp/failproofai-sdk-test +rm -rf /tmp/failproofai-sdk-test && python your_agent.py +cat /tmp/failproofai-sdk-test/events/*.jsonl | python -m json.tool --json-lines +``` + +`AGENTEYE_HOME` sends events somewhere disposable, so you can iterate on the +integration without touching the real directory or shipping test runs to the +platform. Note the SDK reads it late, per flush — so set it before you start the +process, not halfway through. + +**Do not verify by installing the CLI into your agent's environment.** It will +uninstall the SDK you just integrated (§1). Reading back what landed on the +platform is the `fp-cli` skill's job, from a separate environment. + +## 6. Production — the collector has to agree with you + +The SDK writes files. It never talks to the network, so from its point of view a +completely unshipped integration looks perfect. + +In production, the collector must be **running** and reading the **same directory +the SDK is writing to**. That is the whole contract, and both halves fail +silently: + +- Collector not running → files pile up in `events/` forever. The SDK is fine. +- Collector reading a different base dir than the agent writes to — a different + `AGENTEYE_HOME`, a different user's `~`, a container path that isn't mounted — + → files pile up in a directory nobody reads. The SDK is fine. + +So when events are on disk but not on the platform, the SDK is not the suspect. +Compare the two paths first: print the directory your agent is actually writing to +(`python -c "import failproofai_sdk._resolver as r; print(r.get_base_dir())"` in the +agent's own environment, with the agent's own env vars) and check the collector is +running and pointed at the same one. A `.jsonl` count that only grows is the tell. + +Confirming events arrived on the *platform* is deliberately not this skill's job — +that is the `fp-cli` skill, from a **separate environment** (§1). Collector +setup and deployment are your platform's own documentation. + +If the files look right (§5) and the collector is running against the same +directory, the integration is done. + +<!-- ci: no-op touch to exercise the skill-sync trigger (safe to remove) --> diff --git a/sdk/python/skill/agents/openai.yaml b/sdk/python/skill/agents/openai.yaml new file mode 100644 index 000000000..42451fd83 --- /dev/null +++ b/sdk/python/skill/agents/openai.yaml @@ -0,0 +1,8 @@ +# Codex skill configuration (optional). See https://developers.openai.com/codex/skills +# +# Codex reads SKILL.md's `name`/`description` the same way Claude Code does. +# This file only tunes Codex-specific behavior. + +# Let Codex auto-select this skill when a task matches the description +# (set to false to require explicit `$failproofai-sdk` invocation). +allow_implicit_invocation: true diff --git a/sdk/python/skill/references/events.md b/sdk/python/skill/references/events.md new file mode 100644 index 000000000..1fdf1dd16 --- /dev/null +++ b/sdk/python/skill/references/events.md @@ -0,0 +1,234 @@ +# Event catalog + +Every method lives on `failproofai_sdk.event`, is **keyword-only**, and returns `None`. +Nothing here blocks or does I/O — the call queues the event and returns. + +`session_id` and `agent_id` are optional on all fifteen: omit them and they come +from the enclosing `failproofai_sdk.session()` / `failproofai_sdk.agent()` scope. Pass them and +your value wins. Omit `session_id` with nothing bound and you get a `TypeError` +naming the fix; `agent_id` falls back to `"main"` and never raises. See +`integration.md`. + +Emit only what fits the agent. There is no requirement to use every type, and no +penalty for skipping one — except `agent_start`, without which the session does +not exist at all. + +## The 15 events + +Columns: **Required** is beyond `session_id` + `agent_id`, which every event +carries and which you rarely pass by hand. +*Optional fields are omitted from the record entirely when left unset* — they are +not written as `null`. + +| Method | Required | Optional | Notes | +|---|---|---|---| +| `agent_start` | — | `goal`, `parent_id` | **Creates the session.** `parent_id` is the **`agent_id` of the parent agent** — not a session id, not a run id. Pass a session id here and you get no nesting, silently. | +| `agent_end` | — | `outcome`, `summary` | `outcome` must be `failed`/`error`/`timeout`/`rejected` to count as a failure. | +| `agent_pause` | `pause_id` | `reason`, `user_id` | Suspends the agent (waiting for a human, throttled, user-paused) **without ending it**. Starts the paused clock for this `pause_id`. | +| `agent_resume` | `pause_id` | `reason`, `user_id` | Emit **instead of a second `agent_start`** when a paused agent continues. `duration_ms` auto-computed — how long it was paused. | +| `tool_use` | `tool_name`, `tool_call_id` | `input` | Starts the duration clock for this `tool_call_id`. | +| `tool_result` | `tool_name`, `tool_call_id` | `output`, `error` | `duration_ms` auto-computed from the matching `tool_use`. | +| `model_request` | — | `model`, `messages`, `system`, `tools`, `request_id` | `request_id` is what pairs this with its response. | +| `model_response` | — | `model`, `stop_reason`, `input_tokens`, `output_tokens`, `content`, `role`, `request_id`, `error` | Token counts drive spend reporting. `error` marks a failed call — a 429 or a timeout — without a separate `error` event. | +| `error` | `error_type`, `message` | `traceback` | Always counts as an error, whatever else is set. | +| `hook_triggered` | `hook_name`, `hook_id` | `trigger_event`, `input` | Starts the clock for this `hook_id`. | +| `hook_completed` | `hook_name`, `hook_id` | `outcome`, `output`, `error` | `duration_ms` auto-computed. Same `outcome` rule as `agent_end`. | +| `human_wait` | `input_id` | `prompt`, `options`, `reason` | Starts the clock for this `input_id`. | +| `human_input` | `input_id` | `response` | `duration_ms` auto-computed — how long the human took. | +| `human_pause` | — | `reason`, `user_id` | | +| `human_interrupt` | — | `reason`, `user_id`, `at_step` | | + +## Rules that apply to every event + +**Payloads should be structured JSON where possible.** Serialization happens on +a background thread. Unsupported leaves such as `datetime`, `UUID`, `Decimal`, +`set`, `bytes`, or a Pydantic model are stringified automatically rather than +stopping the writer. If downstream analysis needs more than that string form, +serialize the value explicitly into a stable JSON object at the integration +boundary. + +**Custom fields are free, and their *names* are unvalidated:** + +```python +failproofai_sdk.event.tool_use( + session_id=sid, agent_id="planner", + tool_name="web_search", tool_call_id="toolu_01", + input={"query": "..."}, + tenant="acme", retry_count=2, # yours, kept verbatim +) +``` + +A misspelled **optional** name is not an error — it is a new field. `inpt={...}` +is accepted, stored, and invisible; nothing will tell you. When something is +missing from a surface, suspect a typo before suspecting the platform. + +A misspelled **required** name is a plain `TypeError` — `tool_name` and friends +are real parameters, so `tool_nmae="search"` fails loudly at the call site. Only +the optional names are silent. + +**Five names are reserved and raise `ValueError`.** `timestamp`, `session_id`, +`agent_id`, `type`, `environment` — passing any as a custom field. (`session_id` +and `agent_id` are already named args, so Python raises `TypeError` first.) + +**`duration_ms` is yours to pass on eleven of the fifteen.** It is computed for you +on `tool_result`, `hook_completed`, `human_input`, and `agent_resume`, and passing +it to *those four* raises `ValueError`. On the other eleven there is no guard: +`agent_end(..., duration_ms=5)` is accepted and stored as an ordinary custom field. Don't. + +The one place you *should* pass it is **`model_response`**, where nothing computes +it for you. Time the call yourself and pass whole milliseconds as an **`int`** — +`round(seconds * 1000)`. A float raises `ValueError` at the call site, naming the +argument: the server reads this as an unsigned 32-bit integer and would store NULL +for anything else, so the SDK refuses it rather than letting the duration vanish. +The same guard covers `input_tokens` and `output_tokens`. + +**Set `request_id` on both model events.** The same value on the `model_request` +and its `model_response` — a `uuid4().hex`, or the provider's own request id if it +gives you one, so your events line up with your provider logs. It links the pair +in the event detail view. + +It does **not** yet drive the timeline rail or the model latency reports: those +still match concurrent LLM calls (a parallel fan-out, a sub-agent, a retry racing +its original) oldest-first, and will keep bracketing the wrong pairs. **Always +pass `duration_ms` on the `model_response` too** — that is what keeps the reported +duration right even when the bracketing is wrong. + +**Correlation is a process-wide map keyed by your ids.** The SDK holds open +starts there until their matching end arrives. Consequences, in order of how much +they hurt: + +- **Per-run counters are unsafe.** `call_1`, `call_2` — common in home-grown loops + — collide across overlapping runs. The failure is not the missing duration the + docs might lead you to expect; it is a *plausible wrong number attributed to the + wrong run*, which is worse. Reuse your framework's id (Anthropic and OpenAI + tool-call ids are globally unique), or a `uuid4`. `failproofai_sdk.tool_call()` + generates a `uuid4` for you. +- **`tool_call_id` and `hook_id` no longer collide with each other.** They live in + separate namespaces, so a `hook_completed(hook_id="x")` cannot pair with a + pending `tool_use(tool_call_id="x")`. The key is `<kind>:<session_id>:<id>`, so + each id only has to be unique *within one session, for one kind* — two sessions + reusing `call_1` measure independently. Reuse it twice in the SAME session and + the second `tool_use` overwrites the first pending entry, so the first + `tool_result` measures from the wrong start. (If you have read + older guidance saying one shared id space, that was true and is not any more — + the ids are namespaced by the SDK, so nothing on your side changes.) +- **`input_id` and `pause_id` are scoped per session/agent**, so + `human_wait`/`human_input` and `agent_pause`/`agent_resume` cannot collide + across runs at all. +- **The pair must happen in the same process.** A `tool_use` in one worker and a + `tool_result` in another produces two unpaired events and no duration. The same + goes for a human pause that resumes in a different process. +- **The map is capped at 10,000 and evicts oldest-first**, so a long-running + process that leaks orphaned starts can silently lose the duration on legitimate + later pairs. + +**Timestamps are set by the SDK** at the moment you call the method — UTC, +microsecond precision, `Z`-suffixed (`2026-07-17T09:15:22.123456Z`). You cannot +override it (`timestamp` is reserved). + +**There is no event id.** Events carry no unique identifier and no sequence +number. They are ordered by timestamp and correlated by your ids. This means +events are not deduplicable — if you emit the same event twice, that is two +events. + +## What one line looks like + +Each record is one line of JSON in an `event-*.jsonl` file. Key order is stable: +identity first, then the event's own required fields, then `environment`, then +whatever optional and custom fields you set. + +```json +{"timestamp": "2026-07-17T09:15:22.123456Z", "session_id": "run-001", "agent_id": "planner", "type": "tool_use", "tool_name": "web_search", "tool_call_id": "toolu_01", "environment": "production", "input": {"query": "latest AI research"}} +{"timestamp": "2026-07-17T09:15:23.456789Z", "session_id": "run-001", "agent_id": "planner", "type": "tool_result", "tool_name": "web_search", "tool_call_id": "toolu_01", "environment": "production", "output": {"results": ["..."]}, "duration_ms": 1334} +``` + +**Parse it; don't grep it.** The whitespace above is what today's writer happens +to emit — it is not a contract. `grep '"type":"agent_start"'` finds nothing on a +perfectly healthy integration, and reads as "my events are missing". Use +`python -m json.tool --json-lines`, or `jq`. + +`environment` is always present. It is `"dev"` unless you set it — see +`../SKILL.md` §3. + +## A minimal complete run + +The shape to aim for. `agent_start` and `agent_end` bracket everything; the pairs +nest inside. + +```python +import failproofai_sdk + +failproofai_sdk.configure(environment="production") + +sid = "run-001" +failproofai_sdk.event.agent_start(session_id=sid, agent_id="planner", goal="answer the user's question") +try: + failproofai_sdk.event.model_request(session_id=sid, agent_id="planner", model="claude-opus-4-8", + request_id="req-1", + messages=[{"role": "user", "content": "..."}]) + failproofai_sdk.event.model_response(session_id=sid, agent_id="planner", model="claude-opus-4-8", + request_id="req-1", duration_ms=812, + stop_reason="tool_use", input_tokens=1200, output_tokens=95) + + failproofai_sdk.event.tool_use(session_id=sid, agent_id="planner", + tool_name="web_search", tool_call_id="toolu_01", + input={"query": "..."}) + failproofai_sdk.event.tool_result(session_id=sid, agent_id="planner", + tool_name="web_search", tool_call_id="toolu_01", + output={"results": ["..."]}) +# BaseException, not Exception: `asyncio.CancelledError` inherits from +# BaseException, so `except Exception` lets a cancelled run through and the +# session ends with an `agent_start` and no `agent_end` at all. +except BaseException as e: + import traceback + failproofai_sdk.event.error(session_id=sid, agent_id="planner", + error_type=type(e).__name__, message=str(e), + traceback=traceback.format_exc()) + failproofai_sdk.event.agent_end(session_id=sid, agent_id="planner", outcome="failed") + raise +else: + failproofai_sdk.event.agent_end(session_id=sid, agent_id="planner", + outcome="success", summary="answered from 1 search") +``` + +Note `outcome="failed"` in the `except` — not `"failure"`, which silently reads as +a non-failure. + +Threading `sid` through by hand like this is fine for one function and miserable +across a real codebase — which is why you do not have to. The same run, with the +scopes doing the identity and the bracketing: + +```python +with failproofai_sdk.agent("planner", session_id="run-001", goal="answer the user's question"): + failproofai_sdk.event.model_request(model="claude-opus-4-8", request_id="req-1", messages=[...]) + failproofai_sdk.event.model_response(model="claude-opus-4-8", request_id="req-1", duration_ms=812, + stop_reason="tool_use", input_tokens=1200, output_tokens=95) + with failproofai_sdk.tool_call("web_search", input={"query": "..."}) as t: + t.output = {"results": ["..."]} +``` + +`agent_start`/`agent_end`, the `error` event, `outcome="failed"` and the tool +timing all come for free, including on the exception path. See `integration.md`; +on a supported framework, `frameworks.md` gets you the same events with no call +sites at all. + +## Pausing and resuming — not ending + +When a run suspends — waiting for a human, rate-limited, user-paused — do **not** +emit `agent_end` and then a fresh `agent_start` on the way back: that reads as two +separate agents, and the wait counts as active work. Bracket the gap with +`agent_pause` / `agent_resume` instead, reusing one `pause_id`: + +```python +import uuid +pause_id = str(uuid.uuid4()) +failproofai_sdk.event.agent_pause(session_id=sid, agent_id="planner", + pause_id=pause_id, reason="waiting_for_user") +# … the run is parked; a human is deciding … +failproofai_sdk.event.agent_resume(session_id=sid, agent_id="planner", pause_id=pause_id) +``` + +The agent span stays open across the pause (it is not ended); `agent_resume` +carries the paused `duration_ms` when both halves run in the same process, and +paused time is reported separately from active work. Persist `pause_id` if the +resume happens in a different process so it reuses the same value. diff --git a/sdk/python/skill/references/frameworks.md b/sdk/python/skill/references/frameworks.md new file mode 100644 index 000000000..3e9cdfec9 --- /dev/null +++ b/sdk/python/skill/references/frameworks.md @@ -0,0 +1,314 @@ +# Framework integrations + +If the agent runs on LangChain/LangGraph, CrewAI, LlamaIndex or Pydantic AI, you +do not write the instrumentation — you turn it on. The adapters ship inside the +SDK wheel and are imported only when you ask for them. + +```python +import failproofai_sdk +from langgraph.graph import StateGraph # import your framework FIRST + +failproofai_sdk.configure(environment="production") +failproofai_sdk.instrument() # every supported framework already imported + +graph.invoke({"messages": [...]}) # sessions, tools, models, errors all appear +``` + +No call site changes. `graph.invoke()`, `crew.kickoff()`, `await workflow.run()` +and `await agent.run()` are recorded exactly as they are written today. + +## `instrument()` / `uninstrument()` + +```python +failproofai_sdk.instrument() # auto-detect +failproofai_sdk.instrument("crewai") # exactly one +failproofai_sdk.instrument("langchain", session_id=request_id, capture_content=False) +failproofai_sdk.uninstrument() # put everything back +``` + +- **Auto-detect reads what is already imported, not what is installed.** This is + deliberate — a library that imports LangChain to find out whether you use it + costs you a second of startup for nothing. The consequence is an ordering rule: + `configure()` → import the framework → `instrument()` → run. Calling + `instrument()` too early instruments nothing and returns `()` — no exception, + but it does log a warning, so check stderr when a run records nothing. +- **It returns the names it newly instrumented**, as a tuple. Assert on it in + startup code if you want a loud failure: `assert failproofai_sdk.instrument()`. +- **Instrumenting something already active is a no-op** returning `()`. Calling + it from two code paths, or from a reloading dev server, cannot double-record. +- **An unknown name raises `ValueError` listing the valid ones.** A typo that + silently records nothing is the worst available outcome, so this one is loud. + Accepted spellings: `langchain` (aliases `langgraph`, `langchain_core`), + `crewai`, `llama_index` (`llamaindex`, `llama-index`), `pydantic_ai` + (`pydantic-ai`, `pydanticai`). +- **One adapter failing does not cost you the others.** With no argument, an + adapter whose install fails is logged and skipped and the rest still install. +- **Ask what is wired up** rather than guessing, when a run records nothing: + + ```python + from failproofai_sdk.integrations import available, active + + available() # ('crewai', 'langchain', 'llama_index', 'pydantic_ai') — every adapter that ships + active() # ('langchain',) — what is instrumented in THIS process right now + ``` + + An empty `active()` after you called `instrument()` is the ordering bug above: + the framework was not in `sys.modules` yet. +- **`uninstrument()` never raises**, restores the original objects it replaced, + and closes anything still open with `outcome="cancelled"` so teardown does not + leave a session reported as ongoing forever. + +Options are keyword arguments to `instrument()`. The same dict reaches every +adapter, so an option meant for one is ignored by the others rather than raising: + +| Option | Applies to | Effect | +|---|---|---| +| `session_id=` | langchain, crewai, pydantic_ai | Pin every run to this session id. Use it when your service already has a per-request id. | +| `capture_content=False` | langchain, pydantic_ai | Drop prompts, messages and outputs; keep structure, durations and token counts. | +| `capture_messages=False` | llama_index | The same, for LlamaIndex. | +| `include_chains=("rag",)` | langchain | Record these intermediate chains by name. Default: none — see below. | +| `graph_callbacks=False` | langchain | Turn off LangGraph interrupt/resume wiring. Default on. | +| `steps=False` | llama_index | Stop emitting a hook pair per workflow step. Default on. | +| `embeddings=True` | llama_index | Record embedding calls. Default off — they are high volume and low signal. | +| `stale_after=600.0` | llama_index | Seconds before the background reaper closes a span the workflow never finished. | +| `reaper_interval=30.0` | llama_index | How often that reaper runs. | + +## The rule the mappings follow + +> A framework construct becomes an Failproof AI **agent** if and only if it owns an +> LLM decision loop and has its own goal. Everything else with a start and an end +> becomes the closest kind of leaf. + +| Kind | Gets | Examples | +|---|---|---| +| owns a decision loop | `agent_start` / `agent_end` | the top-level graph, crew or workflow, and anything the framework itself calls an agent — a compiled subgraph, a CrewAI `Agent` role, a LlamaIndex `FunctionAgent` | +| something the agent *calls* | `tool_use` / `tool_result` | function tools, retrievers, memory and knowledge queries | +| machinery *around* the agent | `hook_triggered` / `hook_completed` (with `trigger_event`) | LangGraph nodes, LlamaIndex workflow steps, CrewAI flow methods, guardrails | + +**A LangGraph node is a hook, not a nested agent**, and that is the one mapping +decision worth understanding, because it is the one you might be tempted to +"fix". `agent_id` is the primary facet across every session; it has to stay a +small, stable set of labels. Promote `retrieve`, `grade_documents` and +`should_continue` to agents and you drown the real agents in the filter, and the +session ends up labelled with whichever node happened to run first. Hook spans +draw identically on the timeline — same lanes, same durations — and you get a +per-node latency surface for free, with `hook_name` as its own facet. + +## LangChain / LangGraph + +Supported: `langchain-core >=1.4.7,<2`, `langgraph >=1.2,<2`. + +Attaches through LangChain's own callback configuration, so **every** callback +manager the framework builds carries it — including ones created inside chains +you never touch. + +| LangChain / LangGraph | Failproof AI | +|---|---| +| root run (the outermost chain/graph) | `agent_start` / `agent_end` | +| LangGraph node | `hook_triggered` / `hook_completed`, `trigger_event="graph_node"` | +| compiled subgraph | nested `agent_start` / `agent_end`, `agent_id="root/node"` | +| tool run | `tool_use` / `tool_result` | +| retriever run | `tool_use` / `tool_result`, output summarised | +| chat model / LLM run | `model_request` / `model_response`, paired on `request_id` | +| `interrupt()` | `human_wait` + `agent_pause` | +| `Command(resume=...)` | `agent_resume` + `human_input` | +| intermediate chains | **nothing**, unless you name them in `include_chains` | + +**Python 3.10 async nodes must forward `RunnableConfig`.** LangGraph cannot +automatically propagate callback context from an async node into a child +`ainvoke()` on Python 3.10. Without this, the child model or tool appears as a +separate root run: + +```python +from langchain_core.runnables import RunnableConfig + +async def plan(state, config: RunnableConfig): + reply = await model.ainvoke(state["messages"], config=config) + return {"messages": [reply]} +``` + +Python 3.11 and later propagate this context automatically. + +`session_id` resolution, in order — the first that produces a value wins: + +1. `instrument("langchain", session_id=...)`; +2. `config={"metadata": {"failproofai_sdk_session_id": sid}}` on the call — the + documented per-call key, and the one to use in a web service; +3. an enclosing `failproofai_sdk.session()` / `failproofai_sdk.agent()` scope; +4. `metadata["session_id" | "conversation_id" | "thread_id"]`; +5. the root run id. + +It is never synthesised from scratch. A made-up id would split one run into many +sessions, which is a silent wrong answer rather than a loud one. + +Three behaviours that surprise people: + +- **A `GraphInterrupt` is control flow, not an error.** LangGraph raises it + through the same error callback as a genuine failure, so a naive integration + paints every human approval red and ends the run as `failed`. The adapter + treats the whole interrupt family as control flow and emits the HITL pairs + instead. +- **Streaming never produces per-token events.** Time-to-first-token and the + chunk count are folded into the closing `model_response` as `fw_ttft_ms` and + `fw_chunks`. +- **`agent_id` is the graph or chain name**, never a run UUID. Framework ids land + in `fw_run_id` / `fw_node` / `fw_thread_id`. + +## CrewAI + +Supported: `crewai >=1.13,<2` (the release where the span tree and normalised +token usage are both present). Registers a listener on CrewAI's event bus; +`crew.kickoff()` is unchanged. + +| CrewAI | Failproof AI | +|---|---| +| crew kickoff | `agent_start` / `agent_end`, `agent_id` = crew name | +| agent execution | nested `agent_start` / `agent_end`, `agent_id` = the agent's **role** | +| task | no event of its own — folded into the agent execution that runs it | +| flow method | `hook_triggered` / `hook_completed`, `trigger_event="flow_method"` | +| guardrail | hook pair; a tripped guardrail closes with `outcome="rejected"` | +| tool usage | `tool_use` / `tool_result` | +| memory / knowledge query, save, retrieval | `tool_use` / `tool_result` (`memory.query`, `knowledge.search`, …) | +| LLM call | `model_request` / `model_response` | +| LLM stream chunks | folded into the closing `model_response` | + +`agent_id` is the crew name at the top and the **agent role** underneath — +`"researcher"`, `"editor"`. CrewAI's `agent.id` is a UUID and goes to +`fw_agent_id`, never to `agent_id`. CrewAI runs its own internal flow inside +every agent execution; that one is a pass-through and does not become a span. + +## LlamaIndex + +Supported: `llama-index-core >=0.14.23,<0.15`. Registers on the root dispatcher, +so one call covers every workflow and agent in the process. + +| LlamaIndex | Failproof AI | +|---|---| +| `Workflow.run` root span | session + `agent_start` / `agent_end` | +| nested `Workflow.run` span | nested `agent_start` / `agent_end` | +| workflow step | `hook_triggered` / `hook_completed`, `trigger_event="workflow_step"` | +| LLM chat start/end | `model_request` / `model_response`, paired on `request_id` | +| `FunctionTool.call` | `tool_use` / `tool_result` | +| retrieval start/end | `tool_use` / `tool_result`, output summarised | +| a tool that waits for an event | `human_wait` + `agent_pause`, then `agent_resume` + `human_input` | +| embeddings | nothing, unless `embeddings=True` | + +`agent_id` is the `FunctionAgent.name` when there is one and the workflow class +name otherwise — never a span id. + +**Token counts are best-effort here, and deliberately so.** LlamaIndex has no +standard usage field, so the raw usage dict always ships as `usage`, and the +top-level `input_tokens` / `output_tokens` are set **only** when a recognised key +is present. A model integration that names its counters something new gives you a +populated `usage` and blank token columns. That is the honest outcome; a +confident wrong number would be worse. + +**Known gap:** human-in-the-loop is captured only when the wait happens inside a +tool — the pattern LlamaIndex documents. A plain workflow step that waits for an +event is resolved by the runtime before anything observable happens, so there is +no signal to key a pause on. + +## Pydantic AI + +Supported: `pydantic-ai-slim >=2.0,<3`. Every tutorial written for v1 is wrong +for this range: `Agent(instrument=...)` was removed in 2.0. The adapter installs +itself as a capability instead — no OpenTelemetry SDK required, and it cannot +double-count against your own tracing. + +| Pydantic AI | Failproof AI | +|---|---| +| an agent run | `agent_start` / `agent_end` (plus `error` on failure) | +| a model request | `model_request` / `model_response` | +| a tool execution | `tool_use` / `tool_result` | +| graph nodes (`UserPromptNode`, `ModelRequestNode`, `CallToolsNode`) | **nothing** | + +Graph nodes are Pydantic AI's own loop machinery rather than steps you wrote — +unlike a LangGraph node — and everything they do that is worth seeing is already +covered by the model and tool spans. + +`session_id` is the run's `conversation_id`, so a conversation spanning several +runs is one session; an enclosing `failproofai_sdk.session()` / `failproofai_sdk.agent()` scope +always wins. `agent_id` is the `Agent`'s name. + +**One ordering rule, because it bites:** the capability is attached when an +`Agent` is constructed, so **agents built before `instrument()` are not +instrumented**, and agents built while instrumented keep the capability object +after `uninstrument()` (it turns into a pass-through and stops recording). +Construct your agents after `instrument()`, or attach it yourself: + +```python +from failproofai_sdk.integrations.pydantic_ai import FailproofAI + +agent = Agent("openai:gpt-5", capabilities=[FailproofAI()]) +``` + +## Mixing adapters with hand-written events + +They compose, and this is the supported way to add detail an adapter cannot know +about: + +```python +failproofai_sdk.instrument("langchain") + +with failproofai_sdk.agent("planner", goal=question): # your bracket + graph.invoke(...) # adapter events land here + with failproofai_sdk.tool_call("billing_check") as t: # your own tool span + t.output = check(user) +``` + +Adapter events join **that** session, with `parent_id="planner"`. An adapter +resolves identity from its own run id first, then that run's parent chain, then +whatever scope you have open — so a hand-written outer bracket and an adapter +produce one tree, not two. + +## What every adapter guarantees + +- **They never raise into your agent.** Every callback is wrapped: a failure logs + at WARNING with a traceback, and after three failures at the same call site + that site disables itself at ERROR. A broken adapter costs you a log line, not + your agent. Set `FAILPROOFAI_SDK_STRICT=1` to make it re-raise instead — do that in + tests and when debugging an adapter that records nothing. +- **Version ranges are checked at `instrument()` time.** Outside the supported + range: warn once, keep going. Named explicitly but not importable at all: + `ImportError` carrying the install command. `FAILPROOFAI_SDK_STRICT_INTEGRATIONS=1` + promotes every one of those warnings to an exception. +- **Framework-native ids are namespaced `fw_*`** — `fw_run_id`, `fw_node`, + `fw_task_id`, `fw_agent_id`, `fw_thread_id`. Flat, never nested. This is a + safety rule, not a style one: custom fields are merged last, so an un-namespaced + extra called `tool_name` or `duration_ms` would overwrite the real field. +- **Large payloads are truncated**, per field and per event, with a + `…[truncated]` marker. Prompts, retrieved documents and tool outputs are the + three largest strings in an agent process. +- **Every event carries `framework`, `framework_version` and + `integration_version`**, so you can tell adapter output from your own. +- **No per-token events, ever.** Streaming is folded into the closing + `model_response`. +- **Timestamps are stamped when the callback fires.** Nothing is backdated, so an + adapter cannot import a trace that already happened. + +## Verifying an adapter + +Everything in `../SKILL.md` §5 applies unchanged — the events go to the same +files. Four checks that are specific to adapter output: + +1. **The first event of each session is the root `agent_start`.** Anything + emitted before it can leave the session labelled by the wrong actor. +2. **`agent_id` values are names, not UUIDs** — `"planner"`, `"researcher"`, + the graph name. A UUID here means something is being passed through that + should have gone to `fw_agent_id`. +3. **`model_request` and `model_response` share a `request_id`**, and every + `model_response` carries an integer `duration_ms`. +4. **Nothing is left open.** Every `tool_use` has a `tool_result`, every + `agent_start` an `agent_end`. + +If an adapter records nothing at all, in this order: was the framework imported +**before** `instrument()` (auto-detect reads imported modules); did +`instrument()` return a non-empty tuple; and does the run produce anything with +`FAILPROOFAI_SDK_STRICT=1` set, which converts a swallowed adapter error into a raise. + +## A framework that is not on this list + +Use the shipped context managers at the framework's own boundaries — +`failproofai_sdk.agent()` around the run, `failproofai_sdk.tool_call()` in the tool hook. See +`integration.md`. Ask before writing a full callback adapter for an unsupported +framework: adding one to the SDK is usually the better answer. diff --git a/sdk/python/skill/references/install.md b/sdk/python/skill/references/install.md new file mode 100644 index 000000000..1fc961801 --- /dev/null +++ b/sdk/python/skill/references/install.md @@ -0,0 +1,96 @@ +# Installing the SDK + +```bash +pip install failproofai-sdk # or: uv add failproofai-sdk +``` + +The SDK has no dependencies, so that is all it installs. Optional extras pull in +the **framework**, never the adapter — all four adapters ship in the box: + +```bash +pip install 'failproofai-sdk[langchain]' # also: langgraph, crewai, pydantic-ai +pip install 'failproofai-sdk[llamaindex]' # note: no hyphen, unlike the dist name +``` + +Most agents already have their framework installed and never need one of these. + +Then `import failproofai_sdk`. There is no token, no private index, and no wheel to +download by hand — the distribution is on public PyPI. It has no dependencies, so +it cannot conflict with anything already in the agent's environment. + +## The one thing that still goes wrong + +**`pip install agenteye` does not install this SDK, and can uninstall it.** + +`agenteye` was the SDK's distribution name inside the private monorepo, and it is +also the name an old CLI published under. That CLI has moved to `fp-cli` (command +`fp`), but its last release as `agenteye` — version `0.1.22` — is stranded on +public PyPI permanently. PyPI versions cannot be withdrawn and reused, and pip +resolves the highest version, so that build is what the name still resolves to. + +| You run | You get | Symptom | +|---|---|---| +| `pip install agenteye`, nothing installed | the stranded CLI build | `import failproofai_sdk` → `ModuleNotFoundError`; that build ships `agenteye_cli` | +| `pip install agenteye`, SDK already present | the stranded CLI build **alongside** it | Confusing but survivable — different distribution names, so the SDK is not removed | +| `pip install agenteye` on a pre-rename SDK (`agenteye` ≤ `0.0.1b14`) | the stranded CLI build, **replacing the SDK** | The import that worked five minutes ago stops working: same distribution name, higher version, so pip treats it as an upgrade | + +The last row is the dangerous one and it is why the rename happened. It fires +*after* a working integration, when someone wants the CLI to check that events +arrived and installs it into the agent's own environment. Nothing warns them. + +If you want the CLI, it is a separate distribution and installing it cannot touch +`failproofai-sdk` — but give it its own environment anyway: + +```bash +pipx install fp-cli # or: uv tool install fp-cli (the command is `fp`) +``` + +## Migrating from the old `agenteye` distribution + +Two changes, both mechanical: + +```bash +pip uninstall agenteye +pip install failproofai-sdk +``` + +```diff +-import agenteye +-agenteye.configure(base_dir=None, flush_interval=0.5) +-agenteye.event.agent_start(session_id="run-001", agent_id="planner") ++import failproofai_sdk ++failproofai_sdk.configure(base_dir=None, flush_interval=0.5) ++failproofai_sdk.event.agent_start(session_id="run-001", agent_id="planner") +``` + +Every method name, argument, and emitted field is unchanged, and so is every +environment variable: `AGENTEYE_HOME` and `AGENTEYE_ENVIRONMENT` keep their +names. Those are a contract with a daemon that releases separately, so renaming +them from the SDK's side would send events to a directory nothing watches — no +error on either side. + +**One thing on disk did move.** The default spool root is now +`~/.failproofai/custom-agents`, not `~/.agenteye`. `failproofaid` watches both, +so if that is your daemon there is nothing to do and already-spooled batches +still get collected. If you run the older `agenteye-collector`, set +`AGENTEYE_HOME=~/.agenteye` — it reads that variable and nothing else about the +umbrella. (`AGENTEYE_SPOOL_TO_FAILPROOFAI` is retired: it also required the +directory to pre-exist, which nothing created, so it never took effect.) + +## Confirm what you have + +```bash +python -c "import failproofai_sdk; print(failproofai_sdk.__version__)" +``` + +- A version string such as `0.0.1b14` → the SDK. Good. +- `ModuleNotFoundError: No module named 'failproofai_sdk'` → not installed. If + `pip show agenteye` returns something, you installed the wrong name; see above. + +## Pinning + +Pin `failproofai-sdk` in your dependency file like any other package. Never leave +an unpinned `agenteye` requirement anywhere a CI job will resolve it from PyPI — +it will pull the stranded CLI build on the next clean install. If you are +migrating, grep for `agenteye` in every `requirements*.txt`, `pyproject.toml`, +`Pipfile` and Dockerfile, not just the one you remember. diff --git a/sdk/python/skill/references/integration.md b/sdk/python/skill/references/integration.md new file mode 100644 index 000000000..292a5931c --- /dev/null +++ b/sdk/python/skill/references/integration.md @@ -0,0 +1,348 @@ +# Writing the integration + +Identity is ambient. Bind it once per run with a context manager and every +`event.*` call inside — including calls in functions that have never heard of +Failproof AI — lands on the right session and agent. + +You do not write the contextvar layer any more; it ships. This page is how to use +it, and what the two failure modes it exists to prevent look like when you route +around it. + +On a supported framework (LangChain/LangGraph, CrewAI, LlamaIndex, Pydantic AI), +read `frameworks.md` first — `failproofai_sdk.instrument()` does all of this for you. + +## The three scopes + +```python +import failproofai_sdk + +failproofai_sdk.configure(environment="production") + +with failproofai_sdk.agent("planner", goal=question): # agent_start / agent_end + with failproofai_sdk.tool_call("web_search", input={"q": question}) as t: + t.output = search(question) +``` + +| Scope | Emits | Yields | +|---|---|---| +| `failproofai_sdk.session(session_id=None, *, agent_id=None)` | **nothing** — identity only | the session id | +| `failproofai_sdk.agent(agent_id="main", *, session_id=None, goal=None, parent_id=AUTO, outcome="success", summary=None, **fields)` | `agent_start` on entry, `agent_end` on every exit | an `Identity` | +| `failproofai_sdk.tool_call(tool_name, *, tool_call_id=None, input=None, **fields)` | `tool_use` / `tool_result`, timed | a handle — set `.output`, read `.id` | + +Every one of them works as `with` **and** `async with`, with identical semantics +and byte-identical events. Nothing in a scope awaits, so the async form is the +same code; use whichever matches the function you are in. + +Read the current identity anywhere with `failproofai_sdk.current()`, which returns a +frozen `Identity(session_id, agent_id, parent_id, depth)`. `session_id is None` +means nothing is bound. + +**`session()` versus `agent()`.** `session()` binds identity and emits nothing — +reach for it when the run is brackets you do not own (a request handler that +delegates to a framework), or when you want one session id to cover several +`agent()` blocks. `agent()` is what creates the session on the platform, because +a session is *defined* as something that emitted `agent_start`. + +**Defaults, all of them chosen to be hard to get wrong:** + +- `session_id=None` on `agent()`/`session()` inherits an already-bound session, + and generates a `uuid4().hex` only if there is none. A nested scope stays inside + one run rather than splitting it in two. +- `parent_id` defaults to the enclosing agent from the scope stack. Pass + `parent_id=None` to force a root span, or a string to override. +- `tool_call_id` defaults to `uuid4().hex` — unique process-wide, which is what + the correlation map needs (see `events.md`). + +## What `agent()` does on the way out + +This is the exit path hand-written instrumentation always gets wrong, so it is +worth stating exactly: + +| Exception | Events, in order | `outcome` | +|---|---|---| +| none | `agent_end` | `"success"` (or your `outcome=`) | +| any `Exception` | `error`, **then** `agent_end` | `"failed"` | +| `KeyboardInterrupt` / `SystemExit` | `error`, then `agent_end` | `"failed"` | +| `CancelledError` / `GeneratorExit` | `agent_end` only | `"cancelled"` | + +The exception is always re-raised. `error` comes strictly *before* `agent_end`, +because the agent span closes at `agent_end` and anything after it is attributed +to nothing. A cancellation is not a failure and must not pollute the Errors +surface, so it emits no `error` event. + +`tool_call()` on failure emits `tool_result(error="TypeName: message")` and **no +`error` event** — a tool failure the agent loop catches is not a run-level error, +and one that propagates is reported exactly once, by the enclosing `agent()`. + +## Explicit identity still works + +`session_id` and `agent_id` are optional keyword arguments on all 15 `event.*` +methods, not removed ones. Passing them explicitly still works, and still wins +over whatever is bound: + +```python +failproofai_sdk.event.tool_use(session_id=sid, agent_id="planner", + tool_name="web_search", tool_call_id="toolu_01") +``` + +Omit `session_id` with nothing bound and you get a **`TypeError`** naming both +fixes. That is deliberate: the alternative is dropping the event silently, and +this SDK already asks you to debug enough invisible failures. `agent_id` never +raises — it is a label, and it falls back to `"main"`. + +## Don't use a module global + +The shortcut the scopes exist to replace, because it breaks silently: + +```python +# WRONG +_current_session = None # or self.session_id on a shared client +``` + +The moment two runs overlap — two asyncio tasks, two threads, two requests in one +process — they share this variable. Events from both runs get whichever +`session_id` was written last. You do not get an error; you get **one session +containing two runs' events, interleaved**, and another session missing entirely. +Nothing about the output says the data is wrong. + +The scopes bind contextvars instead: per-task and per-thread, so overlapping runs +cannot see each other's identity. The agent stack they keep is immutable for the +same reason — a mutable stack shared by reference between tasks is the same bug +wearing a contextvars costume, and it passes every single-threaded test. + +## Threads will drop your events — `propagate()` is the fix + +This one is worth reading twice, because it is the failure everybody hits. + +`contextvars` propagate into asyncio tasks automatically — a task started inside +an `agent()` block inherits the session. **They do not propagate into new +threads.** A fresh thread starts with an empty context, so nothing is bound +there. + +```python +# WRONG — the worker has no session +pool.submit(dispatch, tool_call) +``` + +Wrap the callable: + +```python +pool.submit(failproofai_sdk.propagate(dispatch), tool_call) +pool.map(failproofai_sdk.propagate(work), items) +threading.Thread(target=failproofai_sdk.propagate(work)).start() +loop.run_in_executor(None, failproofai_sdk.propagate(work), x) +``` + +`propagate(fn)` captures the identity bound *at the moment you call it* and binds +it inside the worker, then restores what was there. One name covers thread pools, +bare threads and `run_in_executor`. + +**Do not reach for `contextvars.copy_context().run` instead.** A `Context` object +cannot be entered by two threads at once — the second gets `RuntimeError: cannot +enter context` — so the copy-context form crashes the caller's worker on any +reuse, which is exactly what `pool.map` and a retried submit do. Mutations made +inside `ctx.run` also persist in that `Context`, so a reused one leaks the +previous call's agent stack into the next run. + +Since events now raise rather than vanish when nothing is bound, an un-propagated +worker announces itself with a `TypeError` naming `failproofai_sdk.propagate` instead of +producing a session that is quietly missing half its events. + +## Where to put the calls + +**One tool dispatcher.** Most agents route every tool through one function. That +is one edit site for all tools: + +```python +def dispatch(tool_call): + with failproofai_sdk.tool_call(tool_call.name, tool_call_id=tool_call.id, + input=tool_call.input) as t: + t.output = TOOLS[tool_call.name](**tool_call.input) + return t.output +``` + +Reuse the framework's own tool-call id — Anthropic and OpenAI both give you one. +It is already unique, and it makes the events line up with your provider logs. + +**One LLM wrapper.** Same idea on the model side. Pass a `request_id` on both +halves so concurrent calls pair correctly, and set `duration_ms` yourself on the +response — it is not auto-computed for model events: + +```python +def call_model(messages, **kw): + rid = uuid.uuid4().hex + started = time.monotonic() + failproofai_sdk.event.model_request(model=MODEL, messages=messages, request_id=rid, + tools=kw.get("tools")) + try: + resp = client.messages.create(model=MODEL, messages=messages, **kw) + # BaseException, not Exception. `asyncio.CancelledError` inherits straight + # from BaseException on every supported Python, so `except Exception` does + # NOT see a cancelled tool — and a cancelled tool is a `tool_use` with no + # `tool_result` after it: an orphaned event that also holds its correlation + # slot until the cap evicts it. Cancellation is the ordinary way an async + # tool ends when a timeout fires or a caller gives up. + except BaseException as e: + failproofai_sdk.event.model_response( + model=MODEL, request_id=rid, error=f"{type(e).__name__}: {e}", + duration_ms=round((time.monotonic() - started) * 1000)) + raise + failproofai_sdk.event.model_response( + model=resp.model, request_id=rid, stop_reason=resp.stop_reason, + input_tokens=resp.usage.input_tokens, output_tokens=resp.usage.output_tokens, + role=resp.role, duration_ms=round((time.monotonic() - started) * 1000)) + return resp +``` + +`duration_ms` must be a whole-millisecond **`int`** — a float raises `ValueError` +at the call site rather than silently emptying the column. + +**Sub-agents.** One session, several actors. Nest the scopes and the wiring is +automatic — the inner `agent()` inherits the session and takes the outer agent as +its `parent_id`: + +```python +async def researcher(topic): + async with failproofai_sdk.agent("researcher", goal=topic): + ... + +async with failproofai_sdk.agent("planner", goal=question): + await researcher(topic) # session inherited, parent_id="planner" +``` + +**Yes, this means several `agent_start` events on one `session_id` — that is +correct and intended.** `SKILL.md` §2 says "emit `agent_start` at the top of the +run"; read that as *once per agent*, not once per session. One `agent_start` per +actor is what makes sub-agents appear as distinct, nested spans. + +If a sub-agent runs in a different function that is called *outside* the parent's +block, pass `session_id=failproofai_sdk.current().session_id` explicitly rather than +letting it generate a new one. + +**A service.** For an HTTP handler or a queue worker you usually already have a +per-run id — request id, job id, trace id. **Use it as the `session_id`** rather +than generating one: then a session in Failproof AI and a request in your own logs +are the same string, and cross-referencing an incident stops being detective +work. + +```python +@app.post("/chat") +async def chat(req: Request, body: ChatBody): + async with failproofai_sdk.agent("assistant", session_id=req.headers["x-request-id"], + goal=body.message): + return await run_agent(body.message) +``` + +## Frameworks + +| Framework | What to do | +|---|---| +| LangChain / LangGraph | `failproofai_sdk.instrument()` — see `frameworks.md` | +| CrewAI | `failproofai_sdk.instrument()` | +| LlamaIndex | `failproofai_sdk.instrument()` | +| Pydantic AI | `failproofai_sdk.instrument()` | +| a plain agent loop | `agent()` around the loop, `tool_call()` in the dispatcher | +| an HTTP agent service | `agent()` in request middleware, keyed on the request id | +| a queue worker | `agent()` around the job handler, keyed on the job id | +| anything else with a callback surface | `agent()` at its outermost boundary, `tool_call()` in its tool hook | + +Adapters and hand-written scopes compose: instrument the framework, wrap the call +in your own `failproofai_sdk.agent(...)`, and the adapter's events join **that** session +with your agent as their parent. `frameworks.md` has the details. + +## Testing your integration + +Point the SDK somewhere disposable and read what came out: + +```python +# conftest.py +import json, pathlib, pytest + +@pytest.fixture +def events(tmp_path, monkeypatch): + monkeypatch.setenv("AGENTEYE_HOME", str(tmp_path)) + import failproofai_sdk + # flush_interval is huge on purpose: it parks the background thread so it + # cannot race our explicit flush. Both paths write a file named only to the + # millisecond, so two flushes in the same millisecond clobber each other and + # you lose events — a flaky test with a real cause. + failproofai_sdk.configure(base_dir=tmp_path, environment="test", flush_interval=3600) + yield lambda: [ + json.loads(line) + for f in sorted((tmp_path / "events").glob("*.jsonl")) + for line in f.read_text().splitlines() + ] +``` + +```python +def test_run_emits_a_session(events): + import failproofai_sdk + with failproofai_sdk.agent("planner", goal="test"): + pass + failproofai_sdk._writer.flush_now() # don't wait for the flush thread + types = [e["type"] for e in events()] + assert types == ["agent_start", "agent_end"] + +def test_failure_is_recorded_as_failed(events): + import failproofai_sdk, pytest + with pytest.raises(ValueError): + with failproofai_sdk.agent("planner"): + raise ValueError("boom") + failproofai_sdk._writer.flush_now() + ev = {e["type"]: e for e in events()} + assert ev["error"]["error_type"] == "ValueError" + assert ev["agent_end"]["outcome"] == "failed" # not "failure" +``` + +`failproofai_sdk._writer.flush_now()` drains the queue synchronously. Without it, a fast +test finishes before the flush cycle and reads an empty directory — a +flaky-looking failure with a real cause. Note the leading underscore: `_writer` is +not a public API, so pin your SDK version if your tests depend on it. + +Assert on `type`, `session_id` and `outcome`. Those are the three that break +silently in production. + +**Test the overlap, not just the happy path.** A single run passes even when +identity is a module global; mixing only shows up once two runs overlap, which is +production and not your laptop: + +```python +import asyncio + +def test_two_runs_do_not_mix(events): + import failproofai_sdk + + async def run(name): + async with failproofai_sdk.agent(name, session_id=name): + await asyncio.sleep(0) # force the interleave + failproofai_sdk.event.tool_use(tool_name="t", tool_call_id=f"{name}-1") + + async def both(): + await asyncio.gather(run("a"), run("b")) + + asyncio.run(both()) + failproofai_sdk._writer.flush_now() + for e in events(): + assert e["agent_id"] == e["session_id"] # no event crossed over +``` + +Without the `await asyncio.sleep(0)` the tasks do not interleave and the test +passes against a broken implementation, which makes it worthless. + +**Add one test with an awkward payload**, because this is the failure that costs +you everything and it is invisible in normal testing: + +```python +def test_unserializable_payload_does_not_kill_the_writer(events): + import failproofai_sdk, datetime + with failproofai_sdk.agent("planner"): + with failproofai_sdk.tool_call("clock") as t: + t.output = {"at": datetime.datetime.now()} # a real tool does this + failproofai_sdk._writer.flush_now() + assert [e["type"] for e in events()] == [ + "agent_start", "tool_use", "tool_result", "agent_end"] +``` + +The awkward value is stringified by the SDK writer, and the later `agent_end` +must still be present. Keep this test because real tool outputs frequently carry +objects outside JSON's native type set. diff --git a/sdk/python/tests/__init__.py b/sdk/python/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/sdk/python/tests/conftest.py b/sdk/python/tests/conftest.py new file mode 100644 index 000000000..65d10438c --- /dev/null +++ b/sdk/python/tests/conftest.py @@ -0,0 +1,124 @@ +"""Suite-wide isolation. + +This SDK is built on module-level singletons, so state leaks between tests in +four ways, and every one of them has bitten this package. + +1. **At interpreter exit.** Two tests build an `EventWriter` with a long flush + interval to inspect its queue and never flush it — the point of those tests. + But every writer registers in `_writer._live_writers`, and + `_flush_all_at_exit` flushes ALL of them when the process ends, long after + pytest has torn its fixtures down. Whatever redirection a test applied is + already undone by then, so `get_base_dir()` resolves to the real spool again. + One run of the queue-cap tests deposited over 160,000 synthetic events into a + live `~/.agenteye`, where a configured collector would have shipped them to a + real dashboard as though an agent had emitted them. + + A fixture cannot fix that, because the write happens after the last fixture + is gone. So the redirection is applied at IMPORT, straight into `os.environ` + rather than through `monkeypatch` — pytest undoes monkeypatch at session end, + and session end is still earlier than the flush. + +2. **`_resolver._base_dir` during a test.** Same destination, different clock. + Every test gets `tmp_path` whether it asked for one or not. + +3. **`_environment` and the writer's flush interval.** `configure()` mutates + process-global state and nothing puts it back. + +4. **The identity contextvars.** A leaked `session_id` in production means + events attributed to the wrong run, accepted at HTTP 200 — the exact silent + failure that layer exists to prevent. So the leak is asserted and the leaking + test *fails*. It is repaired afterwards only so the failure stays readable as + one test rather than cascading through the rest of the run. + +`test_sdk.py` has its own in-file autouse `_reset_environment`; these are +additive and idempotent with it. +""" +import atexit +import os +import shutil +import tempfile + +import pytest + +# ── (1) survives to interpreter exit ───────────────────────────────────────── +# +# `setdefault`, so a developer who exports `AGENTEYE_HOME` to point at a scratch +# spool of their own keeps it. `test_resolver_umbrella.py` deletes this variable +# per test through monkeypatch, so the resolution rules themselves are still +# tested against a clean environment. +_SANDBOX = tempfile.mkdtemp(prefix="failproofai-sdk-tests-") +os.environ.setdefault("AGENTEYE_HOME", _SANDBOX) + +# Registered before `failproofai_sdk._writer` is imported, so it lands EARLIER in +# atexit's LIFO order and therefore runs LAST — after the final flush has written +# into the sandbox we are about to remove. +atexit.register(shutil.rmtree, _SANDBOX, True) + +import failproofai_sdk._environment as _environment # noqa: E402 +import failproofai_sdk._resolver as _resolver # noqa: E402 +from failproofai_sdk import _context, _runtime # noqa: E402 +from failproofai_sdk._events import EventNamespace # noqa: E402 + + +class RecordingWriter: + """A writer that keeps entries in memory. No disk, no flush timing.""" + + def __init__(self) -> None: + self.entries: list[dict] = [] + + def submit(self, entry: dict) -> None: + self.entries.append(entry) + + def last(self) -> dict: + return self.entries[-1] + + def types(self) -> list[str]: + return [e["type"] for e in self.entries] + + +@pytest.fixture(autouse=True) +def _sdk_global_state(tmp_path, monkeypatch): + """(2) and (3): a private spool per test, and process globals restored.""" + monkeypatch.delenv("AGENTEYE_HOME", raising=False) + monkeypatch.delenv("AGENTEYE_ENVIRONMENT", raising=False) + + base_dir = _resolver._base_dir + environment = _environment._environment + flush_interval = _runtime.writer._flush_interval + + _resolver.set_base_dir(tmp_path) + try: + yield + finally: + _resolver.set_base_dir(base_dir) + _environment._environment = environment + _runtime.writer._flush_interval = flush_interval + + +@pytest.fixture(autouse=True) +def _no_context_leak(): + """(4): an unbalanced scope fails the test that left it open.""" + yield + session_id, stack = _context.snapshot() + if session_id is not None or stack: + # Repair before failing: without this every subsequent test in the run + # also fails and the real culprit is impossible to spot. + _context.restore((None, ())) + pytest.fail( + "failproofai_sdk identity contextvars leaked out of this test: " + f"session_id={session_id!r}, agent_stack={stack!r}. " + "A scope was entered without being exited." + ) + + +@pytest.fixture() +def events(monkeypatch): + """Swap the process-wide namespace for a recording one. + + `_scopes` resolves `_runtime.event` at call time precisely so this works. + Tests must not emit through the real writer: `test_sdk.py` asserts on the + exact contents of the events directory. + """ + writer = RecordingWriter() + monkeypatch.setattr(_runtime, "event", EventNamespace(writer)) + return writer diff --git a/sdk/python/tests/integrations/__init__.py b/sdk/python/tests/integrations/__init__.py new file mode 100644 index 000000000..4cd38a9a1 --- /dev/null +++ b/sdk/python/tests/integrations/__init__.py @@ -0,0 +1,6 @@ +"""Per-framework adapter tests. + +A package (not a bare directory) because `tests/` is one: without an +`__init__.py` two files called `test_<framework>.py` in different directories +would collide on the module name under rootdir-based collection. +""" diff --git a/sdk/python/tests/integrations/test_crewai.py b/sdk/python/tests/integrations/test_crewai.py new file mode 100644 index 000000000..97f30fba6 --- /dev/null +++ b/sdk/python/tests/integrations/test_crewai.py @@ -0,0 +1,1299 @@ +"""The CrewAI adapter, against a real Crew and a fake model. + +Every structural test here drives a genuine `crewai.Crew` and then asserts on +**the JSONL the writer actually wrote**. No mocks: a mock-based adapter test +proves the adapter calls the functions the test says it calls, which was never +in doubt. + +Two tests earn their place above the rest: + +* `TestAntiDrift` — every other test in this file would still pass if CrewAI + renamed `setup_listeners`, dropped `started_event_id`, or changed the + predicate that decides whether a handler is async. Our handlers would simply + never be called, the crew would run fine, and we would silently record + nothing. +* `test_handlers_are_all_async` — this is not a style assertion. Measured on + crewai 1.15.8, running the crew in this file with **sync** handlers produced a + wrong event stream in 13 of 25 runs: `emit()` dispatches sync handlers onto a + ten-worker pool, so a `tool_usage_finished` can be handled before its + `tool_usage_started` and the `tool_result` is then **dropped entirely**, not + merely reordered. The same 25 runs with async handlers were correct 25 times. +""" + +import ast +import dataclasses +import inspect +import json +import os +import re +import shutil +import uuid + +import pytest + +import failproofai_sdk +from failproofai_sdk import _runtime, _schema +from failproofai_sdk.integrations import _core + +pytestmark = pytest.mark.framework + +_REQUIRE_FRAMEWORKS = os.environ.get("AGENTEYE_TESTS_REQUIRE_FRAMEWORKS", "").strip().lower() in { + "1", + "true", + "yes", + "on", +} + +# Read at crewai import time, so they have to be set before the import below. +# Without them the first run opens a network client and writes a preference file. +os.environ.setdefault("CREWAI_DISABLE_TELEMETRY", "true") +os.environ.setdefault("OTEL_SDK_DISABLED", "true") +os.environ.setdefault("CREWAI_TRACING_ENABLED", "false") + +try: + from crewai import Agent, Crew, Task + from crewai.events import crewai_event_bus + from crewai.events.base_event_listener import BaseEventListener + from crewai.events.event_context import restore_event_scope + from crewai.events.types.crew_events import CrewKickoffStartedEvent + from crewai.events.types.tool_usage_events import ToolUsageStartedEvent + from crewai.events.utils.handlers import _get_param_count, is_async_handler + from crewai.llms.base_llm import BaseLLM, LLMCallType, llm_call_context + from crewai.tools import BaseTool +except ImportError: # pragma: no cover - exercised only on a bare environment + # `pytest.importorskip` is fail-open: misspell the module and every test in + # the file skips while CI stays green having tested nothing. The framework + # CI leg sets AGENTEYE_TESTS_REQUIRE_FRAMEWORKS=1 to turn that into a hard + # failure. + if _REQUIRE_FRAMEWORKS: + raise + pytest.skip("crewai is not installed", allow_module_level=True) + +from failproofai_sdk.integrations import crewai as adapter_module # noqa: E402 +from failproofai_sdk.integrations.crewai import ( # noqa: E402 + STORE_TOOLS, + TABLE, + FailproofAICrewListener, + _tokens, + adapter, +) + +UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-", re.ASCII) + + +# --------------------------------------------------------------------------- +# A fake model, a fake tool, and a real crew +# --------------------------------------------------------------------------- + +class ScriptedLLM(BaseLLM): + """Replays canned ReAct turns through CrewAI's own event helpers. + + Subclassing `BaseLLM` rather than patching `litellm` matters: the events the + adapter sees are then the framework's real ones, emitted from the real call + site with a real `call_id` scope, not something this file invented. + """ + + responses: list = [] + stream_pieces: int = 0 + raise_at: int = -1 + _index: int = 0 + + def call( + self, + messages, + tools=None, + callbacks=None, + available_functions=None, + from_task=None, + from_agent=None, + response_model=None, + ): + with llm_call_context(): + self._emit_call_started_event( + messages=messages, tools=tools, from_task=from_task, from_agent=from_agent + ) + index = self._index + self._index = index + 1 + if index == self.raise_at: + self._emit_call_failed_event( + error="provider exploded", from_task=from_task, from_agent=from_agent + ) + raise RuntimeError("provider exploded") + reply = self.responses[min(index, len(self.responses) - 1)] + for piece in range(self.stream_pieces): + self._emit_stream_chunk_event( + chunk=f"c{piece}", + from_task=from_task, + from_agent=from_agent, + call_type=LLMCallType.LLM_CALL, + ) + self._emit_call_completed_event( + response=reply, + call_type=LLMCallType.LLM_CALL, + from_task=from_task, + from_agent=from_agent, + messages=messages, + usage={"prompt_tokens": 42, "completion_tokens": 13}, + finish_reason="stop", + ) + return reply + + def supports_function_calling(self) -> bool: + return False + + def supports_stop_words(self) -> bool: + return True + + def get_context_window_size(self) -> int: + return 8192 + + +class Adder(BaseTool): + name: str = "adder" + description: str = 'Adds two integers. Input: {"a": <int>, "b": <int>}' + + def _run(self, a: int = 0, b: int = 0) -> str: + return str(int(a) + int(b)) + + +class Exploder(BaseTool): + name: str = "exploder" + description: str = "Always raises." + + def _run(self, **kwargs) -> str: + raise ValueError("tool blew up") + + +USE_TOOL = 'Thought: I should add.\nAction: adder\nAction Input: {"a": 2, "b": 3}' +USE_EXPLODER = "Thought: I should try.\nAction: exploder\nAction Input: {}" +FINAL = "Thought: done.\nFinal Answer: 5" + + +def build_crew(llm, *, tools=None, guardrail=None, guardrail_max_retries=1): + analyst = Agent( + role="Arithmetic Analyst", + goal="Answer arithmetic questions exactly", + backstory="A careful analyst.", + llm=llm, + tools=tools or [], + verbose=False, + ) + task = Task( + description="What is 2 + 3?", + expected_output="The number.", + agent=analyst, + name="add-two-numbers", + guardrail=guardrail, + guardrail_max_retries=guardrail_max_retries, + ) + return Crew(agents=[analyst], tasks=[task], name="Arithmetic Crew", verbose=False) + + +def tool_crew(**kwargs): + llm = ScriptedLLM(model="scripted/fake-1", responses=[USE_TOOL, FINAL], **kwargs) + return build_crew(llm, tools=[Adder()]) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def _no_network(monkeypatch): + """The layer that survives someone adding a test without reading this file.""" + import socket + + def _blocked(*args, **kwargs): + raise AssertionError("a test tried to open a network connection") + + monkeypatch.setattr(socket.socket, "connect", _blocked) + + +@pytest.fixture(autouse=True) +def _clean_bus_scope(): + """CrewAI's scope stack is a contextvar with a hard depth cap of 100. + + A test that emits a `*_started` event by hand and never emits its ending + event leaves an entry on it forever; a hundred of those and every later + test in the process dies inside `push_event_scope`. + """ + yield + restore_event_scope(()) + + +@pytest.fixture() +def emitted(tmp_path): + """Read back the real JSONL the writer produced during this test. + + The flush interval goes to an hour because event filenames only carry + millisecond resolution: two flushes inside the same millisecond write to the + same path and the second clobbers the first. The background thread is parked + and every flush here is explicit. + """ + _runtime.writer.set_flush_interval(3600) + failproofai_sdk._writer.flush_now() + events_dir = tmp_path / "events" + if events_dir.exists(): + shutil.rmtree(events_dir) + + def read(): + crewai_event_bus.flush(timeout=30) + failproofai_sdk._writer.flush_now() + if not events_dir.exists(): + return [] + return [ + json.loads(line) + for path in sorted(events_dir.glob("*.jsonl")) + for line in path.read_text().splitlines() + if line.strip() + ] + + return read + + +@pytest.fixture() +def instrumented(): + """`instrument("crewai")` for the duration of one test, then put it back.""" + failproofai_sdk.instrument("crewai") + try: + yield adapter + finally: + failproofai_sdk.uninstrument("crewai") + + +def kickoff(crew): + """`kickoff()` flushes the bus *before* emitting crew_kickoff_completed, so + the final event is still in flight when it returns.""" + try: + return crew.kickoff() + finally: + crewai_event_bus.flush(timeout=30) + + +def types_of(events): + return [event["type"] for event in events] + + +# --------------------------------------------------------------------------- +# The representative run +# --------------------------------------------------------------------------- + +class TestRepresentativeRun: + def test_exact_event_type_sequence(self, instrumented, emitted): + result = kickoff(tool_crew()) + events = emitted() + assert result.raw == "5" + assert types_of(events) == [ + "agent_start", # the crew + "agent_start", # the agent that runs the task + "model_request", + "model_response", + "tool_use", + "tool_result", + "model_request", + "model_response", + "agent_end", # the agent + "agent_end", # the crew + ] + + def test_root_agent_start_is_the_sessions_first_event(self, instrumented, emitted): + kickoff(tool_crew()) + events = emitted() + # `agent_sessions.agent_id = any(...)` over an ORDER BY (session_id, ts) + # table returns the FIRST agent_id by time, so the sessions list shows + # whatever came first. It has to be the crew. + assert events[0]["type"] == "agent_start" + assert events[0]["agent_id"] == "Arithmetic Crew" + assert events[0].get("parent_id") is None + assert len({event["session_id"] for event in events}) == 1 + + def test_timestamps_are_monotonic(self, instrumented, emitted): + kickoff(tool_crew()) + stamps = [event["timestamp"] for event in emitted()] + assert stamps == sorted(stamps) + + def test_the_session_is_closed(self, instrumented, emitted): + events = emitted() if kickoff(tool_crew()) else [] + starts = [e for e in events if e["type"] == "agent_start"] + ends = [e for e in events if e["type"] == "agent_end"] + assert len(starts) == len(ends) == 2 + assert [e["outcome"] for e in ends] == ["success", "success"] + # Every leaf closed too: an open tool_use leaves the session `ongoing` + # forever, because agent_end force-closes pauses but not tools. + assert types_of(events).count("tool_use") == types_of(events).count("tool_result") + assert types_of(events).count("model_request") == types_of(events).count( + "model_response" + ) + + def test_agent_ids_are_names_never_uuids(self, instrumented, emitted): + events = emitted() if kickoff(tool_crew()) else [] + agent_ids = {event["agent_id"] for event in events} + assert agent_ids == {"Arithmetic Crew", "Arithmetic Analyst"} + assert not any(UUID_RE.match(value) for value in agent_ids) + # CrewAI's own agent id IS a uuid, and it goes to fw_agent_id. + nested = next(e for e in events if e["type"] == "agent_start" and e.get("parent_id")) + assert nested["parent_id"] == "Arithmetic Crew" + uuid.UUID(nested["fw_agent_id"]) + + def test_every_event_carries_the_framework_triple(self, instrumented, emitted): + events = emitted() if kickoff(tool_crew()) else [] + assert events + for event in events: + assert event["framework"] == "crewai" + assert event["framework_version"] + assert event["integration_version"] == failproofai_sdk.__version__ + + def test_every_leaf_hangs_off_an_open_agent(self, instrumented, emitted): + """Invariant 1: a leaf whose agent_id has no open agent_start makes the + dashboard synthesize a never-ending root span.""" + open_agents = set() + for event in emitted() if kickoff(tool_crew()) else []: + if event["type"] == "agent_start": + open_agents.add(event["agent_id"]) + elif event["type"] == "agent_end": + open_agents.discard(event["agent_id"]) + else: + assert event["agent_id"] in open_agents, event + + +# --------------------------------------------------------------------------- +# Correlation +# --------------------------------------------------------------------------- + +class TestCorrelation: + def test_model_events_pair_on_request_id_and_carry_int_durations( + self, instrumented, emitted + ): + events = emitted() if kickoff(tool_crew()) else [] + requests = [e for e in events if e["type"] == "model_request"] + responses = [e for e in events if e["type"] == "model_response"] + assert len(requests) == len(responses) == 2 + assert [e["request_id"] for e in requests] == [e["request_id"] for e in responses] + assert len({e["request_id"] for e in requests}) == 2 + for response in responses: + # `durationOf` prefers the closing event's duration_ms over + # end-start, which is what keeps model durations correct even when + # the dashboard's FIFO pairing brackets the wrong pair. It must be an + # int: the server's JSON parser drops floats and NULLs the column. + assert isinstance(response["duration_ms"], int) + assert not isinstance(response["duration_ms"], bool) + + def test_model_response_carries_normalized_and_raw_usage(self, instrumented, emitted): + events = emitted() if kickoff(tool_crew()) else [] + response = next(e for e in events if e["type"] == "model_response") + assert response["input_tokens"] == 42 + assert response["output_tokens"] == 13 + assert response["usage"] == { + "input_tokens": 42, + "output_tokens": 13, + "total_tokens": 55, + } + assert response["fw_usage_raw"] == {"prompt_tokens": 42, "completion_tokens": 13} + + def test_tool_events_pair_on_tool_call_id_with_a_duration(self, instrumented, emitted): + events = emitted() if kickoff(tool_crew()) else [] + use = next(e for e in events if e["type"] == "tool_use") + result = next(e for e in events if e["type"] == "tool_result") + assert use["tool_call_id"] == result["tool_call_id"] + assert use["tool_name"] == result["tool_name"] == "adder" + assert use["input"] == {"a": 2, "b": 3} + assert result["output"] == "5" + assert isinstance(result["duration_ms"], int) + # The framework's own uuid4, verbatim — so our rows line up with theirs. + uuid.UUID(use["tool_call_id"]) + + @pytest.mark.parametrize( + ("usage", "expected"), + [ + ({"prompt_tokens": 5, "completion_tokens": 6}, (5, 6, 11)), + ({"input_tokens": 5, "output_tokens": 6}, (5, 6, 11)), + ({"inputTokens": 5, "outputTokens": 6, "totalTokens": 99}, (5, 6, 99)), + ({"prompt_tokens": 5.0, "completion_tokens": "6"}, (5, 6, 11)), + ], + ) + def test_token_counts_read_both_provider_spellings(self, usage, expected): + """Reading only one spelling reports zero tokens for half the providers, + at HTTP 200, forever.""" + input_tokens, output_tokens, normalized = _tokens(usage) + assert (input_tokens, output_tokens, normalized["total_tokens"]) == expected + + @pytest.mark.parametrize("usage", [None, "not a dict", {}, {"prompt_tokens": True}]) + def test_token_counts_survive_a_useless_usage_dict(self, usage): + assert _tokens(usage)[:2] == (None, None) + + +# --------------------------------------------------------------------------- +# Payload discipline +# --------------------------------------------------------------------------- + +def _declared_field_names(): + names = {"timestamp", "session_id", "agent_id", "type", "environment"} + for obj in vars(_schema).values(): + if dataclasses.is_dataclass(obj) and isinstance(obj, type): + names.update(field.name for field in dataclasses.fields(obj)) + names.discard("extra_fields") + return names + + +class TestPayloadDiscipline: + def test_no_event_carries_an_unnamespaced_framework_field(self, instrumented, emitted): + """`_schema._build()` merges extras LAST, so an extra called `tool_name`, + `model` or `outcome` silently overwrites the declared field and changes + the promoted column. Everything framework-specific must be + `fw_*`.""" + allowed = _declared_field_names() | _core.ALLOWED_TOP_LEVEL + events = emitted() if kickoff(tool_crew()) else [] + assert events + for event in events: + for key in event: + assert key.startswith("fw_") or key in allowed, (key, event["type"]) + + def test_the_namespaced_fields_survived_the_guard(self, instrumented, emitted): + events = emitted() if kickoff(tool_crew()) else [] + keys = {key for event in events for key in event if key.startswith("fw_")} + assert {"fw_kind", "fw_agent_id", "fw_task_name", "fw_call_id"} <= keys + assert not (keys & _core.FORBIDDEN_EXTRAS) + + def test_no_per_token_events(self, instrumented, emitted): + """A 500-token response must not be 500 stored rows.""" + crew = build_crew( + ScriptedLLM(model="scripted/fake-1", responses=[FINAL], stream_pieces=7) + ) + kickoff(crew) + events = emitted() + assert types_of(events) == [ + "agent_start", + "agent_start", + "model_request", + "model_response", + "agent_end", + "agent_end", + ] + response = next(e for e in events if e["type"] == "model_response") + assert response["fw_streamed"] is True + assert response["fw_chunks"] == 7 + assert isinstance(response["fw_ttft_ms"], int) + + +# --------------------------------------------------------------------------- +# Failure paths +# --------------------------------------------------------------------------- + +class TestFailures: + def test_a_failed_crew_ends_failed_and_is_not_double_counted( + self, instrumented, emitted + ): + def never_ok(output): + return (False, "never good enough") + + crew = build_crew( + ScriptedLLM(model="scripted/fake-1", responses=[FINAL]), + guardrail=never_ok, + guardrail_max_retries=1, + ) + with pytest.raises(Exception, match="guardrail"): + kickoff(crew) + events = emitted() + + # `sessionSummary.errorCount` counts standalone `error` events AND + # failure outcomes, so emitting both for one failure double-counts it. + assert "error" not in types_of(events) + failed = [e for e in events if e["type"] == "agent_end" and e["outcome"] == "failed"] + assert len(failed) == 1 + assert failed[0]["agent_id"] == "Arithmetic Crew" + assert "guardrail" in failed[0]["summary"] + # "failed", never "failure": the server only counts + # error|failed|timeout|rejected. + assert {e["outcome"] for e in events if e["type"] == "agent_end"} == { + "success", + "failed", + } + # and the session is still closed + assert types_of(events).count("agent_start") == types_of(events).count("agent_end") + + def test_a_tripped_guardrail_is_a_rejected_hook(self, instrumented, emitted): + def never_ok(output): + return (False, "never good enough") + + crew = build_crew( + ScriptedLLM(model="scripted/fake-1", responses=[FINAL]), + guardrail=never_ok, + guardrail_max_retries=1, + ) + with pytest.raises(Exception, match="guardrail"): + kickoff(crew) + events = emitted() + hooks = [e for e in events if e["type"] == "hook_completed"] + assert hooks + for hook in hooks: + # "rejected" is in the server's failure vocabulary, so it paints red + # instead of reading as a hook that succeeded at saying no. + assert hook["outcome"] == "rejected" + assert hook["error"] == "never good enough" + assert isinstance(hook["duration_ms"], int) + triggered = [e for e in events if e["type"] == "hook_triggered"] + assert len(triggered) == len(hooks) + assert {e["trigger_event"] for e in triggered} == {"guardrail"} + + def test_a_tool_error_is_reported_on_the_tool_result_only(self, instrumented, emitted): + llm = ScriptedLLM(model="scripted/fake-1", responses=[USE_EXPLODER, FINAL]) + kickoff(build_crew(llm, tools=[Exploder()])) + events = emitted() + assert "error" not in types_of(events) + results = [e for e in events if e["type"] == "tool_result"] + assert results + for result in results: + assert "tool blew up" in result["error"] + # A tool failure the agent loop catches and retries is not a run-level + # failure, so the crew still ends successfully. + assert [e["outcome"] for e in events if e["type"] == "agent_end"] == [ + "success", + "success", + ] + + def test_a_failed_model_call_is_reported_on_the_model_response( + self, instrumented, emitted + ): + llm = ScriptedLLM(model="scripted/fake-1", responses=[FINAL], raise_at=0) + kickoff(build_crew(llm)) + events = emitted() + assert "error" not in types_of(events) + failed = [e for e in events if e["type"] == "model_response" and e.get("error")] + assert len(failed) == 1 + assert "exploded" in failed[0]["error"] + assert isinstance(failed[0]["duration_ms"], int) + assert failed[0]["request_id"] + # every request still has exactly one response + assert types_of(events).count("model_request") == types_of(events).count( + "model_response" + ) + + +# --------------------------------------------------------------------------- +# Never break the host +# --------------------------------------------------------------------------- + +class TestNeverBreaksTheHost: + def test_a_translator_that_raises_on_every_call_costs_nothing( + self, instrumented, emitted, caplog + ): + class Boom: + def __getattr__(self, name): + raise RuntimeError(f"translator exploded on {name}") + + instrumented._tracker = Boom() + result = kickoff(tool_crew()) + assert result.raw == "5" + assert emitted() == [] + assert any("failed" in record.message for record in caplog.records) + + def test_an_adapter_with_no_tracker_at_all_costs_nothing(self, instrumented): + instrumented._tracker = None + assert kickoff(tool_crew()).raw == "5" + + +# --------------------------------------------------------------------------- +# Install / uninstall discipline +# --------------------------------------------------------------------------- + +class TestInstallDiscipline: + def test_instrument_is_idempotent(self, instrumented): + before = len(instrumented._listener.handlers()) + assert failproofai_sdk.instrument("crewai") == () + assert len(instrumented._listener.handlers()) == before + + def test_uninstrument_removes_every_handler_from_the_bus(self): + failproofai_sdk.instrument("crewai") + listener = adapter._listener + registered = listener.handlers() + assert registered + failproofai_sdk.uninstrument("crewai") + for event_class, handler in registered: + assert handler not in crewai_event_bus._sync_handlers.get(event_class, set()) + assert handler not in crewai_event_bus._async_handlers.get(event_class, set()) + assert adapter._listener is None + + def test_a_run_left_open_is_closed_by_uninstrument(self, emitted): + """agent_end force-closes open pauses but NOT tools; a run that dies with + an open tool_use leaves the session `ongoing` forever.""" + failproofai_sdk.instrument("crewai") + crewai_event_bus.emit( + None, CrewKickoffStartedEvent(crew_name="Half Crew", inputs=None) + ) + crewai_event_bus.emit( + None, ToolUsageStartedEvent(tool_name="adder", tool_args={"a": 1}) + ) + crewai_event_bus.flush(timeout=30) + failproofai_sdk.uninstrument("crewai") + + events = emitted() + assert types_of(events) == ["agent_start", "tool_use", "tool_result", "agent_end"] + assert events[2]["fw_incomplete"] is True + assert events[2]["fw_closed_by"] == "teardown" + assert events[3]["outcome"] == "cancelled" + assert len({event["session_id"] for event in events}) == 1 + + def test_the_session_id_option_pins_the_session(self, emitted): + failproofai_sdk.instrument("crewai", session_id="pinned-session") + try: + kickoff(tool_crew()) + finally: + failproofai_sdk.uninstrument("crewai") + events = emitted() + assert events + assert {event["session_id"] for event in events} == {"pinned-session"} + + def test_install_ignores_options_meant_for_another_adapter(self): + # instrument() hands the SAME options dict to every adapter, so an + # unknown keyword must not be a TypeError that takes out the others. + assert failproofai_sdk.instrument("crewai", some_other_adapters_option=1) == ("crewai",) + failproofai_sdk.uninstrument("crewai") + + def test_two_runs_in_a_row_are_two_sessions(self, instrumented, emitted): + kickoff(tool_crew()) + kickoff(tool_crew()) + events = emitted() + sessions = {event["session_id"] for event in events} + assert len(sessions) == 2 + # ...and neither leaks into the other + first = [e for e in events if e["session_id"] == events[0]["session_id"]] + assert first == events[: len(first)] + + +# --------------------------------------------------------------------------- +# CrewAI-specific structure +# --------------------------------------------------------------------------- + +class TestCrewAIStructure: + def test_the_internal_agent_executor_flow_is_not_an_agent(self, instrumented, emitted): + """CrewAI's agent executor emits FlowStartedEvent(flow_name="AgentExecutor") + inside EVERY agent execution. Treating flow events as agents + unconditionally — which is what the API reads like — puts a spurious + `AgentExecutor` agent inside every single agent, doubling the tree and + poisoning the agent_id facet.""" + kickoff(tool_crew()) + agent_ids = {event["agent_id"] for event in emitted()} + assert "AgentExecutor" not in agent_ids + assert agent_ids == {"Arithmetic Crew", "Arithmetic Analyst"} + + def test_a_task_is_not_its_own_span(self, instrumented, emitted): + """A Task is a subset of the agent execution that runs it. Emitting both + would double every row and render them as siblings; the task rides along + as fw_task_*.""" + events = emitted() if kickoff(tool_crew()) else [] + assert len([e for e in events if e["type"] == "agent_start"]) == 2 + nested = [e for e in events if e["type"] == "agent_start"][1] + assert nested["fw_task_name"] == "add-two-numbers" + uuid.UUID(nested["fw_task_id"]) + + def test_the_crew_span_carries_its_kind_and_name(self, instrumented, emitted): + events = emitted() if kickoff(tool_crew()) else [] + root = events[0] + assert root["fw_kind"] == "crew" + assert root["fw_crew_name"] == "Arithmetic Crew" + + +# --------------------------------------------------------------------------- +# Anti-drift — the highest-value tests in this file +# --------------------------------------------------------------------------- + +class TestAntiDrift: + def test_we_still_override_something_that_exists_on_the_base(self): + """If upstream renames `setup_listeners`, our override becomes dead code + that is never called, the crew runs fine, and every fake-based test in + this file still passes.""" + base_methods = { + name for name, value in vars(BaseEventListener).items() if callable(value) + } + ours = {name for name, value in vars(FailproofAICrewListener).items() if callable(value)} + overridden = ours & base_methods + assert "setup_listeners" in overridden, ( + "FailproofAICrewListener no longer overrides anything BaseEventListener " + f"defines. Base has: {sorted(base_methods)}" + ) + + def test_every_parameter_we_declare_is_still_in_the_base_signature(self): + base_methods = { + name for name, value in vars(BaseEventListener).items() if callable(value) + } + for name in {n for n, v in vars(FailproofAICrewListener).items() if callable(v)}: + if name not in base_methods or name == "__init__": + continue + ours = set(inspect.signature(getattr(FailproofAICrewListener, name)).parameters) + theirs = set(inspect.signature(getattr(BaseEventListener, name)).parameters) + assert ours <= theirs, (name, sorted(ours - theirs)) + + def test_registration_still_happens_in_the_constructor(self): + """`BaseEventListener.__init__` calling `setup_listeners` IS the install + step. If that stops being true, `install()` registers nothing.""" + source = inspect.getsource(BaseEventListener.__init__) + assert "setup_listeners" in source + assert "setup_listeners" in getattr(BaseEventListener, "__abstractmethods__", ()) + + def test_the_bus_api_we_call_still_exists(self): + for name in ("register_handler", "off", "flush", "emit"): + assert callable(getattr(crewai_event_bus, name, None)), name + register = inspect.signature(crewai_event_bus.register_handler).parameters + assert {"event_type", "handler"} <= set(register) + off = inspect.signature(crewai_event_bus.off).parameters + assert {"event_type", "handler"} <= set(off) + + def test_every_event_class_we_map_still_exists(self): + """Resolved through the adapter's own `event_class`, deliberately. + + Asserting against `crewai.events.event_types` directly encoded a + NARROWER rule than the adapter follows, and the gap was invisible: the + flow events live only on `crewai.events`, so a mapping for one resolved + to None, `probe()` disabled it, and nothing failed. + """ + from failproofai_sdk.integrations.crewai import event_class + + for class_name, _ in TABLE: + assert event_class(class_name) is not None, class_name + for class_name in STORE_TOOLS: + assert event_class(class_name) is not None, class_name + + def test_handlers_are_keyed_by_exact_type_with_no_mro_walk(self): + """The reason there is one entry per event class instead of a BaseEvent + catch-all. If this ever gains an MRO walk, the table is redundant; if a + catch-all is added on the assumption that it works, it records nothing.""" + source = inspect.getsource(type(crewai_event_bus).emit) + assert "_sync_handlers.get(event_type" in source + assert "_async_handlers.get(event_type" in source + + def test_handlers_are_all_async(self, instrumented): + """Not a style rule. `emit()` dispatches sync handlers onto a ten-worker + pool and submission order is not execution order: measured on crewai + 1.15.8, this file's crew produced a WRONG event stream — with + `tool_result` and `model_response` silently **dropped**, because their + opening event had not been handled yet — in 13 of 25 runs with sync + handlers, and 0 of 25 with async ones.""" + handlers = instrumented._listener.handlers() + assert handlers + for event_class, handler in handlers: + assert inspect.iscoroutinefunction(handler), event_class + # crewai routes on ITS predicate, not on ours. + assert is_async_handler(handler), event_class + assert _get_param_count(handler) == 2, event_class + + def test_async_handlers_still_run_on_one_ordered_loop(self): + """The property the previous test depends on: async handlers are + scheduled with `run_coroutine_threadsafe` onto a single background loop, + and `call_soon_threadsafe` is FIFO. Sync handlers go to a pool.""" + source = inspect.getsource(type(crewai_event_bus).emit) + assert "run_coroutine_threadsafe" in source + assert "_sync_executor.submit" in source + + def test_the_span_tree_fields_we_build_on_still_exist(self): + from crewai.events.base_events import BaseEvent + + for name in ("event_id", "parent_event_id", "started_event_id", "timestamp"): + assert name in BaseEvent.model_fields, name + + def test_every_event_attribute_we_read_still_exists_somewhere(self): + """Reflect over our own source and check every `event.<field>` we touch + is still a field on at least one event class we register. + + This is what catches a silent rename: `started_event_id` disappearing + would make every pairing fall back to the LIFO heuristic without a single + test failing. + """ + from crewai.events.base_events import BaseEvent + + from failproofai_sdk.integrations.crewai import event_class + + known = set(BaseEvent.model_fields) + for class_name, _ in TABLE: + known |= set(event_class(class_name).model_fields) + + tree = ast.parse(inspect.getsource(adapter_module)) + read = set() + for node in ast.walk(tree): + if ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id == "event" + ): + read.add(node.attr) + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "getattr" + and node.args + and isinstance(node.args[0], ast.Name) + and node.args[0].id == "event" + and len(node.args) > 1 + and isinstance(node.args[1], ast.Constant) + ): + read.add(node.args[1].value) + + assert read, "the reflection found nothing — it has stopped working" + assert read <= known, sorted(read - known) + + +# --------------------------------------------------------------------------- +# Registry wiring +# --------------------------------------------------------------------------- + +class TestRegistry: + def test_autodetect_picks_crewai_up(self): + # Auto-detect installs every framework already imported in this process, + # and by the time the whole suite has run that is all four — so put back + # exactly what this call installed, not just ours. + installed = failproofai_sdk.instrument() + try: + assert "crewai" in installed + finally: + for name in installed: + failproofai_sdk.uninstrument(name) + + def test_the_adapter_matches_the_protocol(self): + assert adapter.name == "crewai" + assert adapter.module == "crewai" + assert callable(adapter.install) + assert callable(adapter.uninstall) + + def test_importing_the_adapter_does_not_happen_at_import_agenteye(self): + # A guarded module-level import of crewai is acceptable *in this module* + # precisely because nothing imports it except instrument("crewai"). + assert "crewai" not in failproofai_sdk.__dict__ + assert not hasattr(failproofai_sdk, "integrations") or True + + +# --------------------------------------------------------------------------- +# Human in the loop +# --------------------------------------------------------------------------- + +class TestHumanInTheLoop: + """A crew blocked on a person was invisible: crewai fires + `HumanFeedbackRequestedEvent`/`HumanFeedbackReceivedEvent` and this adapter + subscribed to neither, so the whole wait was an unexplained gap and the + session's active duration absorbed it. + + LangChain and LlamaIndex both map their HITL surface onto the same four + events, in the same order. This is the crewai one. + """ + + @staticmethod + def _classes(): + from failproofai_sdk.integrations.crewai import event_class + + requested = event_class("HumanFeedbackRequestedEvent") + received = event_class("HumanFeedbackReceivedEvent") + if requested is None or received is None: # pragma: no cover + pytest.skip("this crewai has no human-feedback events") + return requested, received + + def _round_trip(self, *, flow="review_flow", method="approve", feedback="ship it"): + requested, received = self._classes() + crewai_event_bus.emit( + None, + requested( + type="human_feedback_requested", + flow_name=flow, + method_name=method, + output="the draft", + message="Approve this?", + ), + ) + crewai_event_bus.emit( + None, + received( + type="human_feedback_received", + flow_name=flow, + method_name=method, + feedback=feedback, + outcome=None, + ), + ) + crewai_event_bus.flush(timeout=30) + + def test_a_human_wait_emits_all_four_events_in_order(self, instrumented, emitted): + crewai_event_bus.emit(None, CrewKickoffStartedEvent(crew_name="C", inputs=None)) + self._round_trip() + events = emitted() + kinds = types_of(events) + for expected in ("human_wait", "agent_pause", "agent_resume", "human_input"): + assert expected in kinds, f"{expected} missing from {kinds}" + assert kinds.index("human_wait") < kinds.index("agent_pause") + assert kinds.index("agent_pause") < kinds.index("agent_resume") + assert kinds.index("agent_resume") < kinds.index("human_input") + + def test_the_pause_and_the_wait_share_one_id(self, instrumented, emitted): + """Without a shared id the SDK cannot measure either interval, and the + dashboard shows a pause that never closes.""" + crewai_event_bus.emit(None, CrewKickoffStartedEvent(crew_name="C", inputs=None)) + self._round_trip() + events = {e["type"]: e for e in emitted()} + pause_id = events["agent_pause"]["pause_id"] + assert pause_id + assert events["agent_resume"]["pause_id"] == pause_id + assert events["human_wait"]["input_id"] == pause_id + assert events["human_input"]["input_id"] == pause_id + + def test_the_prompt_and_the_answer_are_both_recorded(self, instrumented, emitted): + crewai_event_bus.emit(None, CrewKickoffStartedEvent(crew_name="C", inputs=None)) + self._round_trip(feedback="looks good, ship it") + events = {e["type"]: e for e in emitted()} + assert events["human_wait"]["prompt"] == "Approve this?" + assert events["human_input"]["response"] == "looks good, ship it" + + def test_both_closing_events_carry_a_measured_int_duration(self, instrumented, emitted): + """`agent_pause` -> `agent_resume` is the only thing that feeds pausedMs.""" + crewai_event_bus.emit(None, CrewKickoffStartedEvent(crew_name="C", inputs=None)) + self._round_trip() + events = {e["type"]: e for e in emitted()} + for kind in ("agent_resume", "human_input"): + assert isinstance(events[kind]["duration_ms"], int), kind + + def test_feedback_with_no_request_records_the_answer_but_does_not_resume( + self, instrumented, emitted + ): + """Closing a pause that never opened subtracts a pausedMs interval that + was never added, so the resume is deliberately withheld — but the answer + itself must still reach the Human surface.""" + _, received = self._classes() + crewai_event_bus.emit(None, CrewKickoffStartedEvent(crew_name="C", inputs=None)) + crewai_event_bus.emit( + None, + received( + type="human_feedback_received", + flow_name="f", + method_name="m", + feedback="orphaned answer", + outcome=None, + ), + ) + crewai_event_bus.flush(timeout=30) + + events = emitted() + kinds = types_of(events) + assert "human_input" in kinds + assert "agent_resume" not in kinds + answer = next(e for e in events if e["type"] == "human_input") + assert answer["response"] == "orphaned answer" + # NEVER None: `human_input` requires `input_id`, and passing None raises + # a TypeError inside the customer's event bus. + assert answer["input_id"] + assert answer["fw_orphaned"] is True + + def test_every_hitl_event_lands_on_the_one_session(self, instrumented, emitted): + crewai_event_bus.emit(None, CrewKickoffStartedEvent(crew_name="C", inputs=None)) + self._round_trip() + events = emitted() + assert len({e["session_id"] for e in events}) == 1 + + +# --------------------------------------------------------------------------- +# Nesting: the spans CrewAI hangs real work underneath +# --------------------------------------------------------------------------- + +def _flow_event(name, **kw): + from failproofai_sdk.integrations.crewai import event_class + + klass = event_class(name) + if klass is None: # pragma: no cover - a crewai that dropped the class + pytest.skip(f"this crewai has no {name}") + return klass(**kw) + + +class TestNesting: + """`_nodes` is what `_parent_key` resolves a `parent_event_id` against, and + an id that is not in it falls back to the open ROOT. So every CrewAI span + that can parent other spans has to be recorded there, including the two that + are not agents: + + * a `delegate_work_to_coworker` **tool call** parents the coworker's entire + `AgentExecutionStartedEvent` (measured on 1.15.16: the coworker's + `parent_event_id` IS the tool event's id), so a hierarchical crew's whole + manager/coworker hierarchy collapses onto the crew without it; + * a **flow method** parents a `Crew.kickoff()` made inside it, so the crew + becomes a second root — a whole separate session — without it. + + Neither failure raises, and both look plausible in the dashboard. + """ + + def test_a_delegated_coworker_hangs_off_the_delegating_agent( + self, instrumented, emitted + ): + crew = CrewKickoffStartedEvent(crew_name="Hierarchical Crew", inputs=None) + crewai_event_bus.emit(None, crew) + + manager = Agent(role="Crew Manager", goal="Delegate.", backstory="Manages.") + worker = Agent(role="Researcher", goal="Research.", backstory="Researches.") + task = Task(description="d", expected_output="o", agent=manager) + + manager_span = _flow_event( + "AgentExecutionStartedEvent", + agent=manager, + task=task, + tools=[], + task_prompt="p", + parent_event_id=crew.event_id, + ) + crewai_event_bus.emit(None, manager_span) + # CrewAI's own executor flow, which is a pass-through link. + executor = _flow_event( + "FlowStartedEvent", flow_name="AgentExecutor", parent_event_id=manager_span.event_id + ) + crewai_event_bus.emit(None, executor) + delegate = ToolUsageStartedEvent( + tool_name="delegate_work_to_coworker", + tool_args="{}", + agent_role="Crew Manager", + parent_event_id=executor.event_id, + ) + crewai_event_bus.emit(None, delegate) + coworker_span = _flow_event( + "AgentExecutionStartedEvent", + agent=worker, + task=task, + tools=[], + task_prompt="p", + parent_event_id=delegate.event_id, + ) + crewai_event_bus.emit(None, coworker_span) + crewai_event_bus.flush(timeout=30) + + starts = {e["agent_id"]: e for e in emitted() if e["type"] == "agent_start"} + assert starts["Crew Manager"]["parent_id"] == "Hierarchical Crew" + # The whole point: NOT "Hierarchical Crew". + assert starts["Researcher"]["parent_id"] == "Crew Manager" + + def test_a_crew_inside_a_flow_method_stays_in_one_session(self, instrumented, emitted): + flow = _flow_event("FlowStartedEvent", flow_name="ReviewFlow") + crewai_event_bus.emit(None, flow) + method = _flow_event( + "MethodExecutionStartedEvent", + flow_name="ReviewFlow", + method_name="run_crew", + state={}, + parent_event_id=flow.event_id, + ) + crewai_event_bus.emit(None, method) + crewai_event_bus.emit( + None, + CrewKickoffStartedEvent( + crew_name="Inner Crew", inputs=None, parent_event_id=method.event_id + ), + ) + crewai_event_bus.flush(timeout=30) + + events = emitted() + # One session, not two. A detached crew mints its own session id and the + # run silently becomes two runs. + assert len({e["session_id"] for e in events}) == 1 + inner = next( + e for e in events if e["type"] == "agent_start" and e["agent_id"] == "Inner Crew" + ) + assert inner["parent_id"] == "ReviewFlow" + + +class TestFlowFailure: + """A flow whose method raises emits `FlowFailedEvent` and never + `FlowFinishedEvent`. Unmapped, the flow's `agent_start` is never closed and + the session renders `ongoing` forever — the one outcome this adapter's + fallbacks exist to avoid.""" + + def test_flow_failed_closes_the_span(self, instrumented, emitted): + flow = _flow_event("FlowStartedEvent", flow_name="DoomedFlow") + crewai_event_bus.emit(None, flow) + crewai_event_bus.emit( + None, + _flow_event( + "FlowFailedEvent", + flow_name="DoomedFlow", + error=RuntimeError("sink offline"), + started_event_id=flow.event_id, + parent_event_id=flow.event_id, + ), + ) + crewai_event_bus.flush(timeout=30) + + events = emitted() + ends = [e for e in events if e["type"] == "agent_end"] + assert [e["agent_id"] for e in ends] == ["DoomedFlow"] + assert ends[0]["outcome"] == "failed" + assert "sink offline" in ends[0]["fw_error"] + + def test_flow_failed_is_in_the_table(self): + assert ("FlowFailedEvent", "on_flow_failed") in TABLE + + +class TestConcurrentRoots: + """`_roots` is process-global. With two crews open on two threads, an event + whose parent span has already been popped resolves through `_roots[-1]` — + a coin flip that files one run's events under the OTHER run's session id. + That is silent cross-session corruption, strictly worse than a missing row.""" + + def test_an_orphan_does_not_land_in_the_other_runs_session(self, instrumented, emitted): + # `restore_event_scope(())` between the two kickoffs is what a second + # THREAD would give for free: without it crewai's scope stack makes the + # second crew a child of the first and there is only ever one root, so + # the bug this guards cannot be reached. + with failproofai_sdk.session("session-alpha"): + crewai_event_bus.emit(None, CrewKickoffStartedEvent(crew_name="Alpha", inputs=None)) + restore_event_scope(()) + with failproofai_sdk.session("session-bravo"): + crewai_event_bus.emit(None, CrewKickoffStartedEvent(crew_name="Bravo", inputs=None)) + restore_event_scope(()) + crewai_event_bus.flush(timeout=30) + assert len(adapter._roots) == 2, "the test needs two concurrent roots to mean anything" + + with failproofai_sdk.session("session-alpha"): + # Its parent span is gone (closed, or evicted at _MAX_NODES), so + # this resolves through the root fallback. + crewai_event_bus.emit( + None, + ToolUsageStartedEvent( + tool_name="orphan_tool", + tool_args="{}", + agent_role="Alpha Worker", + parent_event_id=str(uuid.uuid4()), + ), + ) + crewai_event_bus.flush(timeout=30) + + orphan = next(e for e in emitted() if e["type"] == "tool_use") + # NOT "session-bravo": `_roots[-1]` is Bravo. + assert orphan["session_id"] == "session-alpha" + assert orphan["agent_id"] == "Alpha" + + +class TestLiteAgent: + """`Agent.kickoff()` is an agent run with no Crew and no Task. It emits its + OWN execution events, and it is a ROOT. Unmapped, the run has no agent span + at all: its LLM and tool events fall through to whatever ambient scope + exists (`agent_id` "main"), or are dropped outright when there is none.""" + + @staticmethod + def _pair(role="Solo Agent"): + info = {"id": str(uuid.uuid4()), "role": role, "goal": "Answer.", "backstory": "b"} + started = _flow_event( + "LiteAgentExecutionStartedEvent", agent_info=info, tools=[], messages="hi" + ) + crewai_event_bus.emit(None, started) + crewai_event_bus.emit( + None, + _flow_event( + "LiteAgentExecutionCompletedEvent", + agent_info=info, + output="done", + started_event_id=started.event_id, + ), + ) + crewai_event_bus.flush(timeout=30) + return info + + def test_it_gets_its_own_agent_span(self, instrumented, emitted): + info = self._pair() + events = emitted() + assert types_of(events) == ["agent_start", "agent_end"] + assert events[0]["agent_id"] == "Solo Agent" + assert events[0]["goal"] == "Answer." + assert events[0]["fw_lite"] is True + # The UUID goes to fw_agent_id and NEVER to the LowCardinality facet. + assert events[0]["fw_agent_id"] == info["id"] + assert events[1]["outcome"] == "success" + + def test_it_is_a_root_so_it_opens_its_own_session(self, instrumented, emitted): + self._pair() + events = emitted() + assert events[0].get("parent_id") is None + assert len({e["session_id"] for e in events}) == 1 + + def test_the_three_rows_are_in_the_table(self): + mapped = dict(TABLE) + assert mapped["LiteAgentExecutionStartedEvent"] == "on_lite_agent_started" + assert mapped["LiteAgentExecutionCompletedEvent"] == "on_agent_completed" + assert mapped["LiteAgentExecutionErrorEvent"] == "on_agent_error" + + +# --------------------------------------------------------------------------- +# Task(human_input=True) — the HITL surface that is NOT on the event bus +# --------------------------------------------------------------------------- + +def _provider(): + try: + from crewai.core.providers.human_input import SyncHumanInputProvider + except ImportError: # pragma: no cover - a crewai that moved it + pytest.skip("this crewai has no SyncHumanInputProvider") + return SyncHumanInputProvider + + +class TestTaskHumanInput: + """crewai has two HITL surfaces and only one of them is on the bus. + `human_input=True` on a Task runs through + `crewai.core.providers.human_input`, which calls `input()` and emits no + event of any kind — so the entire human wait is billed as active time on the + agent unless this seam is wrapped. It is the only patch in the adapter, + which is why install/restore is asserted as hard as the events are. + """ + + def test_the_four_events_land_on_the_blocked_agent( + self, instrumented, emitted, monkeypatch + ): + crewai_event_bus.emit(None, CrewKickoffStartedEvent(crew_name="C", inputs=None)) + agent = Agent(role="Blocked Analyst", goal="g", backstory="b") + task = Task(description="d", expected_output="o", agent=agent) + crewai_event_bus.emit( + None, + _flow_event( + "AgentExecutionStartedEvent", + agent=agent, + task=task, + tools=[], + task_prompt="p", + ), + ) + crewai_event_bus.flush(timeout=30) + + monkeypatch.setattr("builtins.input", lambda *a: "tighten the wording") + assert _provider()._prompt_input(None) == "tighten the wording" + + events = emitted() + kinds = types_of(events) + for expected in ("human_wait", "agent_pause", "agent_resume", "human_input"): + assert expected in kinds, f"{expected} missing from {kinds}" + assert kinds.index("human_wait") < kinds.index("agent_pause") + assert kinds.index("agent_pause") < kinds.index("agent_resume") + assert kinds.index("agent_resume") < kinds.index("human_input") + + by_type = {e["type"]: e for e in events} + # On the agent that is actually blocked, not on the crew above it. + assert by_type["human_wait"]["agent_id"] == "Blocked Analyst" + assert by_type["human_input"]["response"] == "tighten the wording" + assert by_type["agent_pause"]["pause_id"] == by_type["agent_resume"]["pause_id"] + assert by_type["human_wait"]["fw_surface"] == "task_human_input" + + def test_the_seam_is_restored_on_uninstrument(self): + provider = _provider() + before = provider.__dict__.get("_prompt_input") + before_async = provider.__dict__.get("_prompt_input_async") + failproofai_sdk.instrument("crewai") + assert provider.__dict__.get("_prompt_input") is not before + failproofai_sdk.uninstrument("crewai") + assert provider.__dict__.get("_prompt_input") is before + assert provider.__dict__.get("_prompt_input_async") is before_async + + def test_installing_twice_does_not_wrap_twice(self, instrumented): + # A second install with no restore in between would emit the pause twice + # for one prompt. + adapter._patch_task_human_input() + assert len(adapter._patched) == 2 + + def test_it_is_still_a_staticmethod(self, instrumented): + # `self._prompt_input(context.crew)` is the call site: a bare function + # here would bind and arrive with `self` where `crew` belongs. + assert isinstance(_provider().__dict__["_prompt_input"], staticmethod) + + def test_a_raising_prompt_still_raises(self, instrumented, monkeypatch): + boom = KeyboardInterrupt() + + def _raise(*args): + raise boom + + monkeypatch.setattr("builtins.input", _raise) + with pytest.raises(KeyboardInterrupt) as excinfo: + _provider()._prompt_input(None) + assert excinfo.value is boom diff --git a/sdk/python/tests/integrations/test_langchain.py b/sdk/python/tests/integrations/test_langchain.py new file mode 100644 index 000000000..a5fdb39bf --- /dev/null +++ b/sdk/python/tests/integrations/test_langchain.py @@ -0,0 +1,2212 @@ +"""The LangChain / LangGraph adapter, against the real framework. + +Everything here runs a **real** graph on a **fake** chat model +(`GenericFakeChatModel` — no network, no key, deterministic) and then reads the +JSONL the writer actually produced. Nothing asserts on mock call arguments: the +whole failure mode this adapter exists inside is "the events looked right in a +mock and were wrong on disk". + +Two of these tests are worth more than the rest put together: + +* `test_every_override_still_exists_on_its_framework_base` — if upstream renames + a callback, our override becomes **dead code that is never called** and every + behavioural test below still passes, because they only ever assert on events + we did emit. Reflection over the class is the only thing that catches it. +* `test_the_node_filter_still_matches_real_langgraph_metadata` — the same + problem one layer down. The node/inner-runnable filter is a string comparison + against `metadata["langgraph_node"]`; if that key moves, every node silently + stops being a hook and the timeline just gets shorter. +""" + +import inspect +import json +import logging +import operator +import os +from typing import Annotated, TypedDict + +import pytest + +import failproofai_sdk +from failproofai_sdk.integrations import _compat, _core + +pytestmark = pytest.mark.framework + +# `importorskip` is fail-open: misspell the module and every test here skips +# while CI stays green having tested nothing. The framework CI leg sets this +# env var, which turns the skip into an import error. +if os.environ.get("AGENTEYE_TESTS_REQUIRE_FRAMEWORKS"): + import langchain_core # noqa: F401 + import langgraph # noqa: F401 +else: + pytest.importorskip("langchain_core") + pytest.importorskip("langgraph") + +from langchain_core.documents import Document # noqa: E402 +from langchain_core.language_models.fake_chat_models import ( # noqa: E402 + FakeListChatModel, + GenericFakeChatModel, +) +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage # noqa: E402 +from langchain_core.retrievers import BaseRetriever # noqa: E402 +from langchain_core.runnables import RunnableConfig, RunnableLambda # noqa: E402 +from langchain_core.tools import tool # noqa: E402 +from langgraph.checkpoint.memory import InMemorySaver # noqa: E402 +from langgraph.graph import END, START, StateGraph # noqa: E402 +from langgraph.prebuilt import ToolNode # noqa: E402 +from langgraph.types import Command, Send, interrupt # noqa: E402 + +from failproofai_sdk.integrations import langchain as adapter # noqa: E402 + +WATCHED_LOGGERS = ("failproofai_sdk.integrations", "langchain_core.callbacks.manager") + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +class _Sink(logging.Handler): + """Collects anything the adapter (or LangChain) swallowed. + + LangChain firewalls handler exceptions in `handle_event` — it catches, logs + a WARNING and carries on — and `_core.safe` does the same one layer in. So a + broken translator produces a **green test** and a log line. This turns that + log line into a failure, which is the only way these tests mean anything. + """ + + def __init__(self): + super().__init__(level=logging.WARNING) + self.records = [] + self.allow = False + + def emit(self, record): + self.records.append(record) + + +@pytest.fixture(autouse=True) +def sink(): + handler = _Sink() + loggers = [logging.getLogger(name) for name in WATCHED_LOGGERS] + for logger in loggers: + logger.addHandler(handler) + try: + yield handler + finally: + for logger in loggers: + logger.removeHandler(handler) + if not handler.allow and handler.records: + pytest.fail( + "instrumentation failure was swallowed and only logged:\n" + + "\n".join(handler.format(r) for r in handler.records) + ) + + +@pytest.fixture(autouse=True) +def _adapter_state(monkeypatch, tmp_path): + import shutil + + from failproofai_sdk import _runtime + + monkeypatch.delenv("FAILPROOFAI_SDK_STRICT", raising=False) + monkeypatch.delenv("FAILPROOFAI_SDK_STRICT_INTEGRATIONS", raising=False) + monkeypatch.delenv(adapter.ENV_VAR, raising=False) + _core.set_strict(None) + _compat.set_strict_integrations(None) + _core.reset_failures() + _compat.reset_warnings() + # Instrumentation is process-global and `instrument()` with no argument + # installs every *detected* framework — so another test file that + # auto-detected can leave this adapter active, and `instrument("langchain")` + # would then correctly return () and this file would test nothing. + failproofai_sdk.uninstrument() + # The writer's own thread must not flush while a test is running: filenames + # have millisecond resolution, so two flushes in the same millisecond + # clobber each other and the test reads half its events. + _runtime.writer.set_flush_interval(3600) + # A previous test can legitimately emit *after* it read its events — + # `uninstrument()` closes spans that were still open, which is the point. + # Drain and discard those before this test starts, or they land in this + # test's directory and every assertion about "the first agent_end" is wrong. + _runtime.writer.flush_now() + shutil.rmtree(tmp_path / "events", ignore_errors=True) + yield + failproofai_sdk.uninstrument() + _runtime.writer.flush_now() + _core.reset_failures() + _compat.reset_warnings() + _core.set_strict(None) + _compat.set_strict_integrations(None) + + +@pytest.fixture() +def instrumented(): + assert failproofai_sdk.instrument("langchain") == ("langchain",) + yield + failproofai_sdk.uninstrument("langchain") + + +def read_events(tmp_path): + """Flush and read what actually reached disk, in emission order.""" + failproofai_sdk._writer.flush_now() + rows = [] + for path in sorted((tmp_path / "events").glob("*.jsonl")): + for line in path.read_text(encoding="utf-8").splitlines(): + if line.strip(): + rows.append(json.loads(line)) + return rows + + +def types_of(rows): + return [row["type"] for row in rows] + + +def only(rows, *kinds): + return [row for row in rows if row["type"] in kinds] + + +# --------------------------------------------------------------------------- +# Graphs +# --------------------------------------------------------------------------- + +@tool +def adder(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + +@tool +def exploder(x: int) -> int: + """Always fails.""" + raise RuntimeError("tool boom") + + +class State(TypedDict): + messages: Annotated[list, operator.add] + vals: Annotated[list, operator.add] + answer: str + n: int + + +def tool_calling_message(**kwargs): + return AIMessage( + "planned", + tool_calls=[{"name": "adder", "args": {"a": 1, "b": 2}, "id": "call_abc"}], + usage_metadata={"input_tokens": 11, "output_tokens": 5, "total_tokens": 16}, + **kwargs, + ) + + +def fake_model(*messages): + return GenericFakeChatModel(messages=iter(list(messages))) + + +def subgraph(): + sg = StateGraph(State) + sg.add_node("sub_step", lambda state: {"vals": ["sub"]}) + sg.add_edge(START, "sub_step") + sg.add_edge("sub_step", END) + return sg.compile(name="child_graph") + + +def build_graph(*, model=None, checkpointer=None, name="root_graph"): + """plan -> tools -> child(subgraph) -> fan-out -> ask(interrupt) -> END.""" + model = model or fake_model(tool_calling_message(), AIMessage("again")) + + def plan(state): + reply = model.invoke(state["messages"]) + return {"messages": [reply], "n": state["n"] + 1, "vals": ["plan"]} + + def fan(state): + return [Send("worker", {"i": i}) for i in range(2)] + + def ask(state): + return {"answer": str(interrupt({"prompt": "approve?", "options": ["y", "n"]}))} + + graph = StateGraph(State) + graph.add_node("plan", plan) + graph.add_node("tools", ToolNode([adder])) + graph.add_node("child", subgraph()) + graph.add_node("worker", lambda state: {"vals": ["w"]}) + graph.add_node("ask", ask) + graph.add_edge(START, "plan") + graph.add_edge("plan", "tools") + graph.add_edge("tools", "child") + graph.add_conditional_edges("child", fan, ["worker"]) + graph.add_edge("worker", "ask") + graph.add_edge("ask", END) + return graph.compile(name=name, checkpointer=checkpointer) + + +def build_simple(nodes, *, name="simple", checkpointer=None, edges=None): + graph = StateGraph(State) + previous = START + for node_name, fn in nodes: + graph.add_node(node_name, fn) + graph.add_edge(previous, node_name) + previous = node_name + graph.add_edge(previous, END) + return graph.compile(name=name, checkpointer=checkpointer) + + +def empty_state(**kwargs): + base = {"messages": [HumanMessage("hi")], "vals": [], "answer": "", "n": 0} + base.update(kwargs) + return base + + +def build_looping(model): + """plan -> check -> (plan | END). Exercises a node visited more than once.""" + + def plan(state): + model.invoke(state["messages"]) + return {"n": state["n"] + 1, "vals": ["plan"]} + + def check(state): + return {"vals": ["check"]} + + graph = StateGraph(State) + graph.add_node("plan", plan) + graph.add_node("check", check) + graph.add_edge(START, "plan") + graph.add_edge("plan", "check") + graph.add_conditional_edges( + "check", lambda s: "plan" if s["n"] < 3 else END, {"plan": "plan", END: END} + ) + return graph.compile(name="looper") + + +# --------------------------------------------------------------------------- +# Shape of a representative run +# --------------------------------------------------------------------------- + +def test_event_type_sequence_for_a_representative_run(tmp_path, instrumented): + model = fake_model(tool_calling_message()) + + def plan(state): + return {"messages": [model.invoke(state["messages"])], "vals": ["plan"]} + + def act(state): + adder.invoke({"a": 1, "b": 2}) + return {"vals": ["act"]} + + app = build_simple([("plan", plan), ("act", act)], name="pipeline") + app.invoke(empty_state(), config={"configurable": {"thread_id": "seq"}}) + + assert types_of(read_events(tmp_path)) == [ + "agent_start", + "hook_triggered", # plan + "model_request", + "model_response", + "hook_completed", + "hook_triggered", # act + "tool_use", + "tool_result", + "hook_completed", + "agent_end", + ] + + +def test_the_root_agent_start_is_the_sessions_first_event(tmp_path, instrumented): + build_graph(checkpointer=InMemorySaver()).invoke( + empty_state(), config={"configurable": {"thread_id": "first"}} + ) + rows = read_events(tmp_path) + assert rows[0]["type"] == "agent_start" + assert rows[0]["agent_id"] == "root_graph" + assert rows[0].get("parent_id") is None + # ...and it is the only root agent_start in the session. + roots = [r for r in rows if r["type"] == "agent_start" and r.get("parent_id") is None] + assert len(roots) == 1 + + +def test_every_event_carries_one_session_id_and_the_framework(tmp_path, instrumented): + build_graph(checkpointer=InMemorySaver()).invoke( + empty_state(), config={"configurable": {"thread_id": "sess"}} + ) + rows = read_events(tmp_path) + assert rows + assert {r["session_id"] for r in rows} == {"sess"} + assert {r["framework"] for r in rows} == {"langchain"} + assert all(r["framework_version"] for r in rows) + assert all(r["integration_version"] for r in rows) + + +def test_agent_ids_are_human_readable_names_never_uuids(tmp_path, instrumented): + build_graph(checkpointer=InMemorySaver()).invoke( + empty_state(), config={"configurable": {"thread_id": "names"}} + ) + rows = read_events(tmp_path) + agent_ids = {r["agent_id"] for r in rows} + assert agent_ids == {"root_graph", "root_graph/child"} + for value in agent_ids: + assert not _looks_like_a_uuid(value), value + # Hook names are node names too — `hook_name` is its own facet on /hooks and + # a uuid there is just as poisonous as one in agent_id. + for row in only(rows, "hook_triggered", "hook_completed"): + assert not _looks_like_a_uuid(row["hook_name"]) + + +def _looks_like_a_uuid(value): + import uuid + + try: + uuid.UUID(str(value)) + except (ValueError, AttributeError, TypeError): + return False + return True + + +def test_no_event_carries_an_extra_that_would_shadow_a_declared_field(tmp_path, instrumented): + build_graph(checkpointer=InMemorySaver()).invoke( + empty_state(), config={"configurable": {"thread_id": "shadow"}} + ) + rows = read_events(tmp_path) + assert rows + # `_schema._build()` ends with `result.update(extra)`, so an extra called + # `tool_name`, `model`, `outcome` or `input_tokens` silently OVERWRITES the + # declared field — and therefore the promoted column and the + # server's computed summary — while every behavioural test still passes. + # The allowed key set is derived from `_schema`'s own dataclasses, so adding + # a field there cannot leave a stale copy here. + declared = _declared_fields_by_type() + assert set(declared) == _SCHEMA_TYPES + for row in rows: + allowed = declared[row["type"]] | _core.ALLOWED_TOP_LEVEL + for key in row: + if key.startswith("fw_"): + assert key not in _core.FORBIDDEN_EXTRAS, (row["type"], key) + continue + assert key in allowed, ( + "%s carries %r, which is not a field of that event — if it is an " + "extra it must be namespaced fw_*, or it silently shadows a " + "declared field" % (row["type"], key) + ) + + +def _declared_fields_by_type(): + import dataclasses + import re + + from failproofai_sdk import _schema + + out = {} + for name, obj in vars(_schema).items(): + if not (dataclasses.is_dataclass(obj) and isinstance(obj, type)): + continue + event_type = re.sub(r"(?<!^)(?=[A-Z])", "_", name[: -len("Event")]).lower() + fields = {f.name for f in dataclasses.fields(obj)} - {"extra_fields"} + out[event_type] = fields | {"type", "environment"} + return out + + +_SCHEMA_TYPES = { + "agent_start", "agent_end", "agent_pause", "agent_resume", + "tool_use", "tool_result", "model_request", "model_response", + "hook_triggered", "hook_completed", "error", + "human_wait", "human_input", "human_pause", "human_interrupt", +} + + +def test_only_the_fifteen_known_event_types_are_emitted(tmp_path, instrumented): + app = build_graph(checkpointer=InMemorySaver()) + config = {"configurable": {"thread_id": "vocab"}} + app.invoke(empty_state(), config=config) + app.invoke(Command(resume="yes"), config=config) + assert set(types_of(read_events(tmp_path))) <= _SCHEMA_TYPES + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + +def test_model_events_pair_on_request_id_and_always_carry_an_int_duration( + tmp_path, instrumented +): + app = build_looping(fake_model(*[AIMessage("t%d" % i) for i in range(5)])) + app.invoke(empty_state(), config={"configurable": {"thread_id": "models"}}) + rows = read_events(tmp_path) + + requests = only(rows, "model_request") + responses = only(rows, "model_response") + assert len(requests) == len(responses) == 3 # the loop runs `plan` three times + assert [r["request_id"] for r in requests] == [r["request_id"] for r in responses] + assert len({r["request_id"] for r in requests}) == 3 + + for response in responses: + # Not guarded by the SDK, and `durationOf` prefers the closing event's + # value — which is what keeps model durations correct even though the + # execution graph pairs model events FIFO per agent_id. Must be an int: + # the server's JSON parser drops floats and NULLs the u32 column. + assert isinstance(response["duration_ms"], int) + assert not isinstance(response["duration_ms"], bool) + assert response["duration_ms"] >= 0 + + +def test_model_name_and_tokens_are_normalized(tmp_path, instrumented): + app = build_simple( + [("plan", lambda s: {"messages": [_MODEL.invoke(s["messages"])], "vals": ["p"]})], + name="tokens", + ) + app.invoke(empty_state(), config={"configurable": {"thread_id": "tok"}}) + response = only(read_events(tmp_path), "model_response")[0] + assert response["model"] == "GenericFakeChatModel" + assert response["input_tokens"] == 11 + assert response["output_tokens"] == 5 + # Shipped as a dict as well: both the server summary and the dashboard fall + # back to payload.usage when the promoted columns are absent. + assert response["usage"] == {"input_tokens": 11, "output_tokens": 5, "total_tokens": 16} + + +_MODEL = fake_model(tool_calling_message(), AIMessage("x"), AIMessage("y")) + + +def test_model_request_carries_normalized_messages_not_flattened_prompts( + tmp_path, instrumented +): + model = fake_model(AIMessage("ok")) + app = build_simple( + [("plan", lambda s: {"messages": [model.invoke(s["messages"])], "vals": ["p"]})], + name="msgs", + ) + app.invoke(empty_state(), config={"configurable": {"thread_id": "msg"}}) + request = only(read_events(tmp_path), "model_request")[0] + # `_create_chat_model_run` flattens messages to "Human: hi" before they + # reach the Run object, which loses the roles. We capture them from + # `on_chat_model_start`, where they are still BaseMessage objects. + assert request["messages"] == [{"role": "user", "content": "hi"}] + + +def test_streaming_never_emits_per_token_events(tmp_path, instrumented): + model = FakeListChatModel(responses=["hello there"]) + app = build_simple( + [("stream", lambda s: {"vals": [c.content for c in model.stream(s["messages"])]})], + name="streamer", + ) + app.invoke(empty_state(), config={"configurable": {"thread_id": "stream"}}) + rows = read_events(tmp_path) + # A 500-token response must not become 500 rows against a five-lane rail. + assert len(only(rows, "model_request")) == 1 + assert len(only(rows, "model_response")) == 1 + response = only(rows, "model_response")[0] + assert response["fw_streamed"] is True + assert response["fw_chunks"] >= len("hello there") + assert isinstance(response["fw_ttft_ms"], int) + + +def test_a_failed_model_call_is_reported_on_the_model_span(tmp_path, instrumented, sink): + class Boom(GenericFakeChatModel): + def _generate(self, *args, **kwargs): + raise RuntimeError("model 429") + + model = Boom(messages=iter([AIMessage("never")])) + + def plan(state): + try: + model.invoke(state["messages"]) + except RuntimeError: + return {"vals": ["caught"]} + return {"vals": ["nope"]} + + build_simple([("plan", plan)], name="modelfail").invoke( + empty_state(), config={"configurable": {"thread_id": "mf"}} + ) + rows = read_events(tmp_path) + response = only(rows, "model_response")[0] + assert response["stop_reason"] == "error" + assert "model 429" in response["error"] + assert isinstance(response["duration_ms"], int) + # The node caught it, so nothing above owns the failure and no standalone + # `error` event may appear — `sessionSummary.errorCount` would double-count. + assert not only(rows, "error") + assert only(rows, "agent_end")[0]["outcome"] == "success" + + +# --------------------------------------------------------------------------- +# Tools and retrievers +# --------------------------------------------------------------------------- + +def test_tool_events_pair_on_tool_call_id_and_carry_a_duration(tmp_path, instrumented): + model = fake_model(tool_calling_message()) + + def plan(state): + return {"messages": [model.invoke(state["messages"])], "vals": ["p"]} + + graph = StateGraph(State) + graph.add_node("plan", plan) + graph.add_node("tools", ToolNode([adder])) + graph.add_edge(START, "plan") + graph.add_edge("plan", "tools") + graph.add_edge("tools", END) + graph.compile(name="tools_graph").invoke( + empty_state(), config={"configurable": {"thread_id": "tools"}} + ) + + rows = read_events(tmp_path) + use = only(rows, "tool_use")[0] + result = only(rows, "tool_result")[0] + assert use["tool_call_id"] == result["tool_call_id"] + # The **LLM-issued** id, not our run id: this is what makes our events line + # up with the provider's logs and with the assistant message's tool_calls. + assert use["tool_call_id"] == "call_abc" + assert use["tool_name"] == result["tool_name"] == "adder" + assert isinstance(result["duration_ms"], int) + assert result.get("error") is None + + +def test_a_failed_tool_is_reported_on_the_tool_span_only(tmp_path, instrumented): + def act(state): + try: + exploder.invoke({"x": 1}) + except RuntimeError: + return {"vals": ["caught"]} + return {"vals": ["nope"]} + + build_simple([("act", act)], name="toolfail").invoke( + empty_state(), config={"configurable": {"thread_id": "tf"}} + ) + rows = read_events(tmp_path) + result = only(rows, "tool_result")[0] + assert "tool boom" in result["error"] + # One error, on the span that owns it. No standalone `error` event, and the + # run itself succeeded because the node handled the failure. + assert not only(rows, "error") + assert only(rows, "agent_end")[0]["outcome"] == "success" + assert only(rows, "hook_completed")[0]["outcome"] == "success" + + +def test_retriever_output_is_summarized_never_the_document_text(tmp_path, instrumented): + class Retriever(BaseRetriever): + def _get_relevant_documents(self, query, *, run_manager=None): + return [ + Document(page_content="SECRET" * 500, metadata={"source": "a.txt"}), + Document(page_content="SECRET" * 500, metadata={"source": "b.txt"}), + ] + + retriever = Retriever() + build_simple( + [("fetch", lambda s: {"vals": [len(retriever.invoke("q"))]})], name="rag" + ).invoke(empty_state(), config={"configurable": {"thread_id": "rag"}}) + + rows = read_events(tmp_path) + use = only(rows, "tool_use")[0] + result = only(rows, "tool_result")[0] + assert use["tool_name"] == "retriever:Retriever" + assert result["output"] == {"n": 2, "sources": ["a.txt", "b.txt"]} + assert "SECRET" not in json.dumps(rows) + + +# --------------------------------------------------------------------------- +# Graph structure +# --------------------------------------------------------------------------- + +def test_a_langgraph_node_is_a_hook_not_a_nested_agent(tmp_path, instrumented): + build_graph(checkpointer=InMemorySaver()).invoke( + empty_state(), config={"configurable": {"thread_id": "hooks"}} + ) + rows = read_events(tmp_path) + hooks = {r["hook_name"] for r in only(rows, "hook_triggered")} + # `sub_step` is the subgraph's own node — also a hook, under the nested agent. + assert hooks == {"plan", "tools", "child", "worker", "ask", "sub_step"} + for row in only(rows, "hook_triggered"): + assert row["trigger_event"] == "graph_node" + # Nodes must never inflate agent_id: it is a LowCardinality column and the + # primary facet, and `agent_sessions.agent_id = any(...)` would label the + # session with whichever node happened to run first. + assert "plan" not in {r["agent_id"] for r in rows} + + +def test_a_compiled_subgraph_becomes_a_nested_agent(tmp_path, instrumented): + build_graph(checkpointer=InMemorySaver()).invoke( + empty_state(), config={"configurable": {"thread_id": "sub"}} + ) + rows = read_events(tmp_path) + nested = [r for r in only(rows, "agent_start") if r.get("parent_id")] + assert len(nested) == 1 + assert nested[0]["agent_id"] == "root_graph/child" + assert nested[0]["parent_id"] == "root_graph" + # The subgraph's own node runs under the nested agent, and the agent closes. + inner = [r for r in rows if r["agent_id"] == "root_graph/child"] + assert types_of(inner) == ["agent_start", "hook_triggered", "hook_completed", "agent_end"] + assert inner[1]["hook_name"] == "sub_step" + + +def test_intermediate_runnables_and_edge_functions_emit_nothing(tmp_path, instrumented): + rows = [] + + def plan(state): + return {"vals": ["p"]} + + graph = StateGraph(State) + graph.add_node("plan", plan) + graph.add_node("done", lambda s: {"vals": ["d"]}) + graph.add_edge(START, "plan") + graph.add_conditional_edges( + "plan", _named_edge, {"done": "done", END: END} + ) + graph.add_edge("done", END) + graph.compile(name="edges").invoke( + empty_state(), config={"configurable": {"thread_id": "edges"}} + ) + rows = read_events(tmp_path) + names = {r["hook_name"] for r in only(rows, "hook_triggered")} + # `_named_edge` is a Runnable with its own run and it inherits the node's + # `langgraph_node` metadata; only `run.name == metadata["langgraph_node"]` + # keeps it out. Emitting it would bury the timeline in machinery. + assert names == {"plan", "done"} + + +def _named_edge(state): + return "done" + + +def test_a_node_visited_repeatedly_produces_one_hook_pair_per_visit(tmp_path, instrumented): + app = build_looping(fake_model(*[AIMessage("t%d" % i) for i in range(5)])) + app.invoke(empty_state(), config={"configurable": {"thread_id": "loop"}}) + rows = read_events(tmp_path) + plans = [r for r in only(rows, "hook_triggered") if r["hook_name"] == "plan"] + assert len(plans) == 3 + assert len({r["hook_id"] for r in plans}) == 3 + completed = [r for r in only(rows, "hook_completed") if r["hook_name"] == "plan"] + assert {r["hook_id"] for r in plans} == {r["hook_id"] for r in completed} + + +def test_a_parallel_fan_out_produces_one_hook_pair_per_branch(tmp_path, instrumented): + build_graph(checkpointer=InMemorySaver()).invoke( + empty_state(), config={"configurable": {"thread_id": "fan"}} + ) + rows = read_events(tmp_path) + workers = [r for r in only(rows, "hook_triggered") if r["hook_name"] == "worker"] + assert len(workers) == 2 + assert len({r["hook_id"] for r in workers}) == 2 + + +def test_every_event_belongs_to_an_agent_whose_start_is_open(tmp_path, instrumented): + app = build_graph(checkpointer=InMemorySaver()) + config = {"configurable": {"thread_id": "open"}} + app.invoke(empty_state(), config=config) + app.invoke(Command(resume="yes"), config=config) + + open_agents = set() + for row in read_events(tmp_path): + if row["type"] == "agent_start": + open_agents.add(row["agent_id"]) + continue + # The dashboard parents every leaf to the open agent with the same + # agent_id and SYNTHESISES a never-ending root span when there is none. + assert row["agent_id"] in open_agents, row + if row["type"] == "agent_end": + open_agents.discard(row["agent_id"]) + assert not open_agents + + +# --------------------------------------------------------------------------- +# Human in the loop +# --------------------------------------------------------------------------- + +def test_interrupt_and_resume_emit_both_pairs_in_order(tmp_path, instrumented): + app = build_graph(checkpointer=InMemorySaver()) + config = {"configurable": {"thread_id": "hitl"}} + first = app.invoke(empty_state(), config=config) + assert "__interrupt__" in first + result = app.invoke(Command(resume="approved"), config=config) + assert result["answer"] == "approved" + + rows = read_events(tmp_path) + hitl = types_of(only(rows, "human_wait", "agent_pause", "agent_resume", "human_input")) + # Neither pair alone is enough: only agent_pause<->agent_resume feeds + # `pausedMs`, and only human_wait<->human_input carries the prompt, the + # answer and `pendingHuman`. + assert hitl == ["human_wait", "agent_pause", "agent_resume", "human_input"] + + wait = only(rows, "human_wait")[0] + pause = only(rows, "agent_pause")[0] + resume = only(rows, "agent_resume")[0] + answer = only(rows, "human_input")[0] + assert wait["input_id"] == pause["pause_id"] == resume["pause_id"] == answer["input_id"] + assert wait["prompt"] == "approve?" + assert wait["options"] == ["y", "n"] + assert answer["response"] == "approved" + assert isinstance(resume["duration_ms"], int) + + +def test_an_interrupt_is_control_flow_not_an_error(tmp_path, instrumented): + app = build_graph(checkpointer=InMemorySaver()) + config = {"configurable": {"thread_id": "ctrl"}} + app.invoke(empty_state(), config=config) + app.invoke(Command(resume="yes"), config=config) + + rows = read_events(tmp_path) + # LangGraph reports the GraphInterrupt through on_chain_error with no + # special case, so without the GraphBubbleUp check every human approval + # would paint a red error and a failed agent. + assert not only(rows, "error") + assert [r["outcome"] for r in only(rows, "agent_end")] == ["success", "success"] + asks = [r for r in only(rows, "hook_completed") if r["hook_name"] == "ask"] + assert [r["outcome"] for r in asks] == ["paused", "success"] + assert asks[0].get("error") is None + + +def test_the_agent_stays_open_across_the_pause_so_one_run_is_one_span( + tmp_path, instrumented +): + app = build_graph(checkpointer=InMemorySaver()) + config = {"configurable": {"thread_id": "span"}} + app.invoke(empty_state(), config=config) + app.invoke(Command(resume="yes"), config=config) + + rows = read_events(tmp_path) + roots = [r for r in only(rows, "agent_start") if not r.get("parent_id")] + # One agent_start, one agent_end, across two `.invoke()` calls. Closing the + # agent at the first invoke would force-close the open pause and zero out + # the only interval that measures how long the human took. + assert len(roots) == 1 + assert len([r for r in only(rows, "agent_end") if r["agent_id"] == "root_graph"]) == 1 + assert rows[-1]["type"] == "agent_end" + assert {r["session_id"] for r in rows} == {"span"} + + +def test_interrupt_events_survive_without_the_graph_lifecycle_callbacks( + tmp_path, monkeypatch +): + """The exception path alone must produce the whole HITL round trip. + + `GraphCallbackHandler.on_interrupt` is new in langgraph 1.2 and, as shipped, + is not delivered to a handler installed through `register_configure_hook` at + all — so the fallback is not a legacy branch, it is the load-bearing one on + any install where the wrap does not apply. + """ + failproofai_sdk.instrument("langchain", graph_callbacks=False) + try: + app = build_graph(checkpointer=InMemorySaver()) + config = {"configurable": {"thread_id": "nolifecycle"}} + app.invoke(empty_state(), config=config) + app.invoke(Command(resume="yes"), config=config) + rows = read_events(tmp_path) + finally: + failproofai_sdk.uninstrument("langchain") + + assert types_of(only(rows, "human_wait", "agent_pause", "agent_resume", "human_input")) == [ + "human_wait", + "agent_pause", + "agent_resume", + "human_input", + ] + assert not only(rows, "error") + + +def test_the_two_interrupt_paths_do_not_double_emit(tmp_path, instrumented): + """Both the lifecycle callback and the exception path fire; `Interrupt.id` + dedups them. A regression here doubles every pause in the dashboard.""" + app = build_graph(checkpointer=InMemorySaver()) + config = {"configurable": {"thread_id": "dedup"}} + app.invoke(empty_state(), config=config) + rows = read_events(tmp_path) + assert len(only(rows, "human_wait")) == 1 + assert len(only(rows, "agent_pause")) == 1 + + +# --------------------------------------------------------------------------- +# Failures +# --------------------------------------------------------------------------- + +def test_a_node_failure_fails_the_agent_and_is_counted_once(tmp_path, instrumented): + def boom(state): + raise ValueError("node exploded") + + app = build_simple([("boom", boom)], name="failing") + with pytest.raises(ValueError): + app.invoke(empty_state(), config={"configurable": {"thread_id": "fail"}}) + + rows = read_events(tmp_path) + hook = only(rows, "hook_completed")[0] + assert hook["outcome"] == "failed" + assert "node exploded" in hook["error"] + # `"failed"`, never `"failure"` — the server only counts + # error|failed|timeout|rejected as a failure. + assert only(rows, "agent_end")[0]["outcome"] == "failed" + # The hook already owns this failure. A standalone `error` event as well + # would make `sessionSummary.errorCount` report two failures for one + # exception, on every failed run. + assert not only(rows, "error") + + +def test_a_failure_no_span_owns_produces_exactly_one_error_event(tmp_path, instrumented): + from langgraph.errors import GraphRecursionError + + model = fake_model(*[AIMessage("t%d" % i) for i in range(50)]) + + def plan(state): + model.invoke(state["messages"]) + return {"n": state["n"] + 1, "vals": ["p"]} + + graph = StateGraph(State) + graph.add_node("plan", plan) + graph.add_edge(START, "plan") + graph.add_conditional_edges("plan", lambda s: "plan", {"plan": "plan"}) + app = graph.compile(name="runaway") + with pytest.raises(GraphRecursionError): + app.invoke( + empty_state(), + config={"configurable": {"thread_id": "recursion"}, "recursion_limit": 4}, + ) + + rows = read_events(tmp_path) + # The recursion limit is enforced by the Pregel loop, not by a node, so no + # leaf reported it: this is exactly the case where a standalone `error` + # event is the only way the failure reaches the Errors surface. + assert len(only(rows, "error")) == 1 + assert only(rows, "error")[0]["error_type"] == "GraphRecursionError" + assert only(rows, "agent_end")[0]["outcome"] == "failed" + # Strictly before agent_end: the graph closes the agent span at agent_end, + # so an error after it is attributed to nothing. + assert types_of(rows)[-2:] == ["error", "agent_end"] + + +def test_a_translator_that_raises_on_every_call_cannot_break_the_graph( + tmp_path, monkeypatch, instrumented, sink +): + sink.allow = True # the whole point is that the failures are logged, not raised + + def explode(*args, **kwargs): + raise RuntimeError("translator is broken") + + for name in ("_on_start", "_on_end", "_stash", "_stash_error", "_stash_messages"): + monkeypatch.setattr(adapter, name, explode) + + model = fake_model(tool_calling_message()) + + def plan(state): + return {"messages": [model.invoke(state["messages"])], "vals": ["p"]} + + def act(state): + return {"vals": [adder.invoke({"a": 2, "b": 3})]} + + app = build_simple([("plan", plan), ("act", act)], name="broken") + result = app.invoke(empty_state(), config={"configurable": {"thread_id": "broken"}}) + + # The customer's run is untouched: right answer, no exception, no missing work. + assert result["vals"] == ["p", 5] + assert result["messages"][-1].content == "planned" + assert sink.records + + +def test_the_translators_swallow_by_policy_not_by_accident(monkeypatch, sink): + """`FAILPROOFAI_SDK_STRICT=1` is what makes the failure policy testable. + + Without it you can only ever prove "the customer's run still worked", never + "we swallowed the right thing" — and an adapter that swallowed + `BaseException` would pass the first check while silently breaking + cancellation in every async application that installed it. + """ + sink.allow = True # `safe()` logs the swallow, which is the whole design + assert getattr(adapter._on_start, "__failproofai_safe__", False) + assert getattr(adapter._on_end, "__failproofai_safe__", False) + + def explode(_run): + raise RuntimeError("boom") + + guarded = _core.safe(explode) + guarded(object()) # swallowed by default + + monkeypatch.setenv("FAILPROOFAI_SDK_STRICT", "1") + _core.set_strict(None) + with pytest.raises(RuntimeError): + guarded(object()) + + def cancel(_run): + raise KeyboardInterrupt + + _core.set_strict(False) + # A BaseException is never swallowed, strict or not. + with pytest.raises(KeyboardInterrupt): + _core.safe(cancel)(object()) + + +def test_strict_mode_actually_surfaces_through_a_real_graph(monkeypatch, sink, instrumented): + """The check above proves `safe()` re-raises. It does NOT prove the caller + ever sees it — and for a while, they didn't. + + LangChain's `handle_event` catches every handler exception and logs + "Error in <handler>.<callback> callback" unless the handler sets + `raise_error`. We hard-coded that False, so under `FAILPROOFAI_SDK_STRICT=1` + `safe()` re-raised straight into LangChain's firewall and the fault was + swallowed one layer further out: the escape hatch silently did nothing on + the adapter people are most likely to be debugging. `raise_error` now + follows strict mode. + + Note this asserts through a real `graph.invoke`, not against `safe()`. The + isolated test above passed the entire time the feature was broken. + """ + sink.allow = True + + graph = StateGraph(State) + graph.add_node("bump", lambda state: {"vals": ["bumped"]}) + graph.add_edge(START, "bump") + graph.add_edge("bump", END) + compiled = graph.compile(name="strict_probe") + + def explode(self, *args, **kwargs): + raise RuntimeError("translator exploded") + + monkeypatch.setattr(_core.RunTracker, "emit", explode) + + # Default: the customer's graph is untouched and still returns the answer. + _core.set_strict(False) + assert compiled.invoke({"vals": [], "messages": []})["vals"] == ["bumped"] + + # Strict: the fault reaches the caller instead of vanishing into a log line. + monkeypatch.setenv("FAILPROOFAI_SDK_STRICT", "1") + _core.set_strict(None) + with pytest.raises(RuntimeError, match="translator exploded"): + compiled.invoke({"vals": [], "messages": []}) + + +def test_two_graphs_on_two_threads_never_mix_sessions(tmp_path, instrumented): + """All adapter state is module-global — it has to be, because the configure + hook builds a fresh handler per callback manager. So the isolation has to + come from the run-id keys, and that is what this proves.""" + import threading + + app = build_simple([("n", lambda s: {"vals": ["x"]})], name="threaded") + barrier = threading.Barrier(4) + + def worker(name): + barrier.wait(timeout=10) + for _ in range(3): + app.invoke(empty_state(), config={"configurable": {"thread_id": name}}) + + threads = [threading.Thread(target=worker, args=("t%d" % i,)) for i in range(4)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=30) + + rows = read_events(tmp_path) + assert {r["session_id"] for r in rows} == {"t0", "t1", "t2", "t3"} + per_session = {} + for row in rows: + per_session.setdefault(row["session_id"], []).append(row["type"]) + for session, kinds in per_session.items(): + assert kinds.count("agent_start") == 3, (session, kinds) + assert kinds.count("agent_end") == 3, (session, kinds) + assert kinds.count("hook_triggered") == 3, (session, kinds) + + +# --------------------------------------------------------------------------- +# Session resolution and interop +# --------------------------------------------------------------------------- + +def test_session_id_prefers_the_documented_metadata_key_over_thread_id( + tmp_path, instrumented +): + build_simple([("n", lambda s: {"vals": ["x"]})], name="s").invoke( + empty_state(), + config={ + "configurable": {"thread_id": "the-thread"}, + "metadata": {adapter.SESSION_METADATA_KEY: "chosen"}, + }, + ) + assert {r["session_id"] for r in read_events(tmp_path)} == {"chosen"} + + +def test_session_id_falls_back_to_thread_id(tmp_path, instrumented): + """Verified against langgraph 1.2.10, contra the widely-reported claim that + `thread_id` is no longer visible to callbacks: langchain-core's + `ensure_config` stopped promoting it, and langgraph's `_PROPAGATE_TO_METADATA` + puts it back.""" + build_simple([("n", lambda s: {"vals": ["x"]})], name="s").invoke( + empty_state(), config={"configurable": {"thread_id": "from-thread"}} + ) + assert {r["session_id"] for r in read_events(tmp_path)} == {"from-thread"} + + +def test_session_id_falls_back_to_the_root_run_id(tmp_path, instrumented): + build_simple([("n", lambda s: {"vals": ["x"]})], name="s").invoke(empty_state()) + sessions = {r["session_id"] for r in read_events(tmp_path)} + assert len(sessions) == 1 + # A run id, not a synthesised one: a made-up session splits one run in two. + assert _looks_like_a_uuid(next(iter(sessions))) + + +def test_an_ambient_agent_scope_and_the_adapter_produce_one_tree(tmp_path, instrumented): + with failproofai_sdk.agent("planner", goal="do the thing"): + build_simple([("n", lambda s: {"vals": ["x"]})], name="inner").invoke( + empty_state(), config={"configurable": {"thread_id": "ignored"}} + ) + rows = read_events(tmp_path) + assert len({r["session_id"] for r in rows}) == 1 + starts = only(rows, "agent_start") + assert [r["agent_id"] for r in starts] == ["planner", "inner"] + # This is the whole interop story: a hand-written outer bracket and an + # adapter must produce one tree, not two disconnected sessions. + assert starts[1]["parent_id"] == "planner" + + +def test_an_explicit_session_id_option_wins(tmp_path): + failproofai_sdk.instrument("langchain", session_id="forced") + try: + build_simple([("n", lambda s: {"vals": ["x"]})], name="s").invoke( + empty_state(), config={"configurable": {"thread_id": "ignored"}} + ) + finally: + failproofai_sdk.uninstrument("langchain") + assert {r["session_id"] for r in read_events(tmp_path)} == {"forced"} + + +# --------------------------------------------------------------------------- +# Install / uninstall +# --------------------------------------------------------------------------- + +def test_uninstrument_stops_recording_and_is_idempotent(tmp_path): + failproofai_sdk.instrument("langchain") + app = build_simple([("n", lambda s: {"vals": ["x"]})], name="s") + app.invoke(empty_state(), config={"configurable": {"thread_id": "on"}}) + before = len(read_events(tmp_path)) + assert before + + assert failproofai_sdk.uninstrument("langchain") == ("langchain",) + assert failproofai_sdk.uninstrument("langchain") == () + assert adapter.ENV_VAR not in os.environ + + app.invoke(empty_state(), config={"configurable": {"thread_id": "off"}}) + assert len(read_events(tmp_path)) == before + + +def test_instrumenting_twice_does_not_double_record(tmp_path): + failproofai_sdk.instrument("langchain") + assert failproofai_sdk.instrument("langchain") == () + try: + build_simple([("n", lambda s: {"vals": ["x"]})], name="s").invoke( + empty_state(), config={"configurable": {"thread_id": "twice"}} + ) + finally: + failproofai_sdk.uninstrument("langchain") + rows = read_events(tmp_path) + assert len(only(rows, "agent_start")) == 1 + assert len(only(rows, "hook_triggered")) == 1 + + +def test_autodetect_picks_up_langchain(tmp_path): + installed = failproofai_sdk.instrument() + try: + assert "langchain" in installed + finally: + failproofai_sdk.uninstrument() + + +def test_open_leaves_are_closed_when_the_root_run_ends(tmp_path, instrumented): + """`agent_end` force-closes open pauses but not tools, models or humans.""" + from failproofai_sdk.integrations.langchain import _RunInfo, _STATE + + def leaky(state): + # Simulate a framework that never delivered the end callback: register a + # tool run by hand and leave it open. + with _STATE.lock: + root = next(i for i in _STATE.runs.values() if i.kind == "root") + orphan = _RunInfo( + id="orphan", parent=root.id, name="ghost", run_type="tool", kind="tool" + ) + orphan.root = root.id + orphan.session = root.session + orphan.tool_call_id = "orphan" + _STATE.runs["orphan"] = orphan + _STATE.tracker.link("orphan", root.id) + _STATE.tracker.emit( + "tool_use", "orphan", parent_key=root.id, tool_name="ghost", tool_call_id="orphan" + ) + return {"vals": ["x"]} + + build_simple([("leaky", leaky)], name="leaky").invoke( + empty_state(), config={"configurable": {"thread_id": "leak"}} + ) + rows = read_events(tmp_path) + ghosts = [r for r in only(rows, "tool_result") if r["tool_name"] == "ghost"] + assert len(ghosts) == 1 + assert ghosts[0]["fw_incomplete"] is True + assert rows[-1]["type"] == "agent_end" + + +# --------------------------------------------------------------------------- +# Async +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_ainvoke_produces_the_same_shape(tmp_path, instrumented): + model = fake_model(tool_calling_message()) + + async def plan(state, config: RunnableConfig): + return { + "messages": [await model.ainvoke(state["messages"], config=config)], + "vals": ["p"], + } + + async def act(state, config: RunnableConfig): + return {"vals": [await adder.ainvoke({"a": 1, "b": 2}, config=config)]} + + app = build_simple([("plan", plan), ("act", act)], name="async_pipeline") + await app.ainvoke(empty_state(), config={"configurable": {"thread_id": "async"}}) + + assert types_of(read_events(tmp_path)) == [ + "agent_start", + "hook_triggered", + "model_request", + "model_response", + "hook_completed", + "hook_triggered", + "tool_use", + "tool_result", + "hook_completed", + "agent_end", + ] + + +# --------------------------------------------------------------------------- +# Structural anti-drift — the highest-value tests in this file +# --------------------------------------------------------------------------- + +def test_every_override_still_exists_on_its_framework_base(): + """Reflect over the handler and check it still overrides something real. + + This is the test that catches the failure mode nothing else can. If + LangChain renames `on_tool_error` or drops `_start_trace`, our method stops + being called, we emit nothing for that path, and every behavioural test in + this file still passes — they assert on the events we *did* emit, and a + silent gap looks exactly like a run that had no tool errors. The in-repo + precedent is `cli/agenteye_cli/_click_compat.py`, where an `isinstance` + check went quietly always-False while its own anti-drift test stayed green. + """ + cls = adapter.FailproofAITracer + overrides = { + name: fn + for name, fn in vars(cls).items() + if inspect.isfunction(fn) and not name.startswith("__") + } + assert len(overrides) >= 10, "reflection found nothing — the test would be vacuous" + + for name, fn in sorted(overrides.items()): + base = next((b for b in cls.__mro__[1:] if name in vars(b)), None) + assert base is not None, ( + "%s overrides nothing on any base — it is dead code that the " + "framework will never call" % name + ) + base_fn = vars(base)[name] + base_params = inspect.signature(base_fn).parameters + for param_name, param in inspect.signature(fn).parameters.items(): + if param.kind in (param.VAR_KEYWORD, param.VAR_POSITIONAL): + continue + # Deliberately no "...or the base takes **kwargs" escape hatch: a + # renamed parameter would still be swallowed by the base's **kwargs + # and our own parameter would silently never be filled. + assert param_name in base_params, ( + "%s.%s declares %r, which no longer appears in %s.%s%s" + % (cls.__name__, name, param_name, base.__name__, name, + inspect.signature(base_fn)) + ) + + +def test_the_graph_lifecycle_overrides_are_bound_to_the_real_langgraph_base(): + """`on_interrupt` lives on a stand-in when langgraph is too old. + + If the import silently fell back on a machine that *has* langgraph 1.2, + langgraph's `isinstance(h, GraphCallbackHandler)` filter would never match + us and HITL would go dark with nothing to show for it. + """ + from langgraph.callbacks import GraphCallbackHandler + + assert adapter._GraphCallbackHandler is GraphCallbackHandler + assert issubclass(adapter.FailproofAITracer, GraphCallbackHandler) + for name in ("on_interrupt", "on_resume"): + assert name in vars(GraphCallbackHandler) + + +def test_the_handler_flags_the_callback_manager_depends_on(): + cls = adapter.FailproofAITracer + # AsyncCallbackManager dispatches sync handlers through run_in_executor + # unless run_inline, and that hop can REORDER callbacks — which scrambles + # timestamp order and breaks every pairing in this file. + assert cls.run_inline is True + + # raise_error follows FAILPROOFAI_SDK_STRICT rather than being a constant, so it + # has to be read off an INSTANCE -- which is also how LangChain reads it. + # Normally False: LangChain already firewalls handler exceptions, and + # raising would take the customer's graph down with our bug. Under strict + # it must be True, or LangChain's firewall swallows the exception `safe()` + # re-raises and the escape hatch does nothing at all here. + handler = cls() + _core.set_strict(False) + assert handler.raise_error is False + _core.set_strict(True) + assert handler.raise_error is True + _core.set_strict(None) + + from langchain_core.callbacks.base import BaseCallbackHandler + + assert hasattr(BaseCallbackHandler, "run_inline") + assert hasattr(BaseCallbackHandler, "raise_error") + + +def test_the_configure_hook_api_is_still_the_one_we_build_on(): + from langchain_core.tracers import context + + assert callable(context.register_configure_hook) + params = list(inspect.signature(context.register_configure_hook).parameters) + assert params[:4] == ["context_var", "inheritable", "handle_class", "env_var"] + # There is deliberately no deregister API — uninstall() has to work by + # emptying the ContextVar and the env var instead. If one ever appears, this + # assertion is the reminder to use it. + assert not hasattr(context, "unregister_configure_hook") + + +def test_the_control_flow_exception_hierarchy_is_still_where_we_look(): + from langgraph.errors import GraphBubbleUp, GraphInterrupt, ParentCommand + + assert adapter._GraphBubbleUp is GraphBubbleUp + assert issubclass(GraphInterrupt, GraphBubbleUp) + assert issubclass(ParentCommand, GraphBubbleUp) + # ...and the payload shape `_interrupts_of` reads. + from langgraph.types import Interrupt + + interrupt_obj = Interrupt(value={"prompt": "p"}, id="abc") + assert adapter._interrupts_of(GraphInterrupt([interrupt_obj])) == (interrupt_obj,) + # A ParentCommand is control flow but carries a Command, not interrupts, so + # it must not be mistaken for a human pause. + assert adapter._is_control_flow(ParentCommand(Command(resume=1))) + assert adapter._interrupts_of(ParentCommand(Command(resume=1))) == () + assert not adapter._is_control_flow(ValueError("real")) + + +def test_the_lifecycle_event_dataclasses_still_carry_the_fields_we_read(): + import dataclasses + + from langgraph.callbacks import GraphInterruptEvent, GraphResumeEvent + + interrupt_fields = {f.name for f in dataclasses.fields(GraphInterruptEvent)} + assert {"run_id", "interrupts"} <= interrupt_fields + assert "run_id" in {f.name for f in dataclasses.fields(GraphResumeEvent)} + + from langgraph.types import Interrupt + + assert {"value", "id"} <= {f.name for f in dataclasses.fields(Interrupt)} + + +def test_the_node_filter_still_matches_real_langgraph_metadata(tmp_path): + """The node filter is a string comparison. Prove the strings still exist. + + `metadata["langgraph_node"] == run.name` is what separates a node from every + inner Runnable that inherits the same metadata. If the key is renamed, every + node stops being a hook and the only symptom is a shorter timeline. + """ + from langchain_core.tracers.base import BaseTracer + + seen = [] + + class Probe(BaseTracer): + run_inline = True + + def _persist_run(self, run): + pass + + def _start_trace(self, run): + super()._start_trace(run) + seen.append((run.name, dict(run.metadata or {}), run.run_type)) + + graph = StateGraph(State) + graph.add_node("only_node", lambda s: {"vals": ["x"]}) + graph.add_edge(START, "only_node") + graph.add_edge("only_node", END) + graph.compile(name="probe").invoke( + empty_state(), config={"callbacks": [Probe()], "configurable": {"thread_id": "t"}} + ) + + root_name, root_meta, _ = seen[0] + node_name, node_meta, _ = seen[1] + assert root_meta.get("langgraph_node") is None + assert node_meta["langgraph_node"] == node_name == "only_node" + assert "langgraph_checkpoint_ns" in node_meta + assert node_meta["thread_id"] == "t" + # And a subgraph node's checkpoint namespace is still `|`-separated, which + # is how nested agents are derived without recognising a Pregel object. + seen.clear() + outer = StateGraph(State) + outer.add_node("child", subgraph()) + outer.add_edge(START, "child") + outer.add_edge("child", END) + outer.compile(name="outer").invoke( + empty_state(), config={"callbacks": [Probe()], "configurable": {"thread_id": "t2"}} + ) + inner = [m for name, m, _ in seen if m.get("langgraph_node") == name == "sub_step"] + assert inner and "|" in inner[0]["langgraph_checkpoint_ns"] + + +def test_the_tool_call_id_still_reaches_the_run_object(): + """`tool_call_id` is read off `run.extra`, where `_create_tool_run` parks + the callback kwargs. If that stops happening we would silently fall back to + the run id and every tool would stop lining up with the provider's logs.""" + from langchain_core.tracers.base import BaseTracer + + seen = [] + + class Probe(BaseTracer): + run_inline = True + + def _persist_run(self, run): + pass + + def _start_trace(self, run): + super()._start_trace(run) + if run.run_type == "tool": + seen.append(dict(run.extra or {})) + + model = fake_model(tool_calling_message()) + + def plan(state): + return {"messages": [model.invoke(state["messages"])], "vals": ["p"]} + + graph = StateGraph(State) + graph.add_node("plan", plan) + graph.add_node("tools", ToolNode([adder])) + graph.add_edge(START, "plan") + graph.add_edge("plan", "tools") + graph.add_edge("tools", END) + graph.compile(name="ids").invoke( + empty_state(), config={"callbacks": [Probe()], "configurable": {"thread_id": "t"}} + ) + assert seen and seen[0].get("tool_call_id") == "call_abc" + + +def test_usage_metadata_survives_to_on_llm_end(): + """The primary token source. The two fallbacks exist because providers + disagree; this asserts the primary is still the primary.""" + from langchain_core.tracers.base import BaseTracer + + seen = [] + + class Probe(BaseTracer): + run_inline = True + + def _persist_run(self, run): + pass + + def on_llm_end(self, response, *, run_id, **kwargs): + seen.append(adapter._usage(response)) + return super().on_llm_end(response, run_id=run_id, **kwargs) + + model = fake_model(tool_calling_message()) + model.invoke([HumanMessage("hi")], config={"callbacks": [Probe()]}) + assert seen == [{"input_tokens": 11, "output_tokens": 5, "total_tokens": 16}] + + +# --------------------------------------------------------------------------- +# A root run that is itself a leaf +# --------------------------------------------------------------------------- +# +# `ChatOpenAI(...).invoke(...)` outside any graph arrives as ONE run with no +# parent and `run_type="chat_model"`. Handled only as a root it produced +# `agent_start`/`agent_end` and nothing else: no `model_request`, no +# `model_response`, so the model name, both token counts and the latency of a +# direct model call were dropped while the trace still looked populated. +# +# Direct `.invoke()` is not an edge case — a classifier, a summariser and a +# one-shot rewrite are all shaped exactly like this. + + +def test_a_bare_model_call_still_emits_its_model_pair(tmp_path, instrumented): + with failproofai_sdk.session(): + fake_model(AIMessage("hi", usage_metadata={ + "input_tokens": 7, "output_tokens": 3, "total_tokens": 10, + })).invoke("say hi") + + rows = read_events(tmp_path) + kinds = types_of(rows) + assert "model_request" in kinds, ( + f"a bare model call emitted {kinds} — the model pair is missing, so the " + f"model name, token counts and latency of every direct .invoke() are lost" + ) + assert "model_response" in kinds + + +def test_a_bare_model_call_records_tokens_and_an_int_duration(tmp_path, instrumented): + with failproofai_sdk.session(): + fake_model(AIMessage("hi", usage_metadata={ + "input_tokens": 7, "output_tokens": 3, "total_tokens": 10, + })).invoke("say hi") + + response = only(read_events(tmp_path), "model_response")[0] + assert response["input_tokens"] == 7 + assert response["output_tokens"] == 3 + # u32 column: a float silently NULLs it server-side. + assert isinstance(response["duration_ms"], int) + assert response["model"] + + +def test_a_bare_model_calls_pair_sits_inside_its_agent_span(tmp_path, instrumented): + """Order matters: the dashboard closes the agent span at `agent_end`, so a + `model_response` after it is attributed to nothing.""" + with failproofai_sdk.session(): + fake_model(AIMessage("hi")).invoke("say hi") + + kinds = types_of(read_events(tmp_path)) + assert kinds.index("agent_start") < kinds.index("model_request") + assert kinds.index("model_response") < kinds.index("agent_end") + + +def test_a_bare_model_pair_shares_one_request_id(tmp_path, instrumented): + with failproofai_sdk.session(): + fake_model(AIMessage("hi")).invoke("say hi") + + rows = read_events(tmp_path) + request = only(rows, "model_request")[0] + response = only(rows, "model_response")[0] + assert request["request_id"] == response["request_id"] + + +def test_a_graph_run_is_not_treated_as_a_leaf(tmp_path, instrumented): + """The fix is additive and must not fire for a chain-typed root.""" + build_simple([("only", lambda state: {"vals": ["x"]})]).invoke(empty_state()) + rows = read_events(tmp_path) + # A graph root emits no model pair of its own — only its nodes do. + assert types_of(rows).count("agent_start") == 1 + + +# --------------------------------------------------------------------------- +# Two roots that merely OVERLAP are not a resume +# --------------------------------------------------------------------------- +# +# `_start_root` reuses an existing session's agent when that agent is still +# open, because that is what an interrupt/resume looks like: the paused +# `.invoke()` deliberately did not close its agent and the resuming one must not +# open a second root span for the same logical run. +# +# "Still open" is ALSO true of two roots that merely overlap in time under one +# session id, and that is not exotic — langchain-core opens one root run **per +# input** for `.batch()`, and any two requests carrying the same conversation id +# through `SESSION_METADATA_KEY` do the same. Read as a resume, the second root +# got no `agent_start` at all, its work was relabelled with the first root's +# `agent_id`, the first root to finish closed the shared agent, and everything +# the other root emitted afterwards resolved to nothing and was DROPPED. +# +# The discriminator is `open_pauses`: `_end_root` skips `agent_end` exactly when +# it is non-empty, which is the only way an agent outlives its root, and +# `_suspend` is the only thing that fills it. + + +def test_two_overlapping_roots_in_one_session_are_two_agents(tmp_path, instrumented): + import threading + + # The barrier is the whole point: both roots are guaranteed to be OPEN at + # the same time, which is the state that used to be misread as a resume. + # Without it this races and passes against the bug about half the time. + barrier = threading.Barrier(2, timeout=10) + + def hold(state): + barrier.wait() + return {"vals": ["x"]} + + app = build_simple([("n", hold)], name="overlap") + + def run(): + app.invoke( + empty_state(), + config={"metadata": {adapter.SESSION_METADATA_KEY: "one-session"}}, + ) + + threads = [threading.Thread(target=run) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=30) + + rows = read_events(tmp_path) + assert {r["session_id"] for r in rows} == {"one-session"} + starts = only(rows, "agent_start") + ends = only(rows, "agent_end") + assert len(starts) == 2, ( + f"two overlapping roots produced {len(starts)} agent_start(s): the second " + f"root was read as a resume of the first" + ) + assert len(ends) == 2 + # Distinct runs, not one run reported twice. + assert len({r["fw_run_id"] for r in starts}) == 2 + # Nothing was dropped on the way: each root ran the node once. + assert types_of(rows).count("hook_triggered") == 2 + assert types_of(rows).count("hook_completed") == 2 + # `sink` (autouse) fails this test on the "could not resolve a session for + # run ... and is dropping its events" warning the old behaviour produced, + # which is the other half of the regression and the half that was silent. + + +def test_a_genuine_interrupt_resume_is_still_one_agent_not_two(tmp_path, instrumented): + """The counterweight: `open_pauses` must not disable the resume path. + + Deleting the resume branch would also "fix" the overlap bug above, at the + cost of splitting every human approval into two root spans and zeroing the + `agent_pause` -> `agent_resume` interval that is the only measure of how + long the human took. + """ + app = build_graph(checkpointer=InMemorySaver()) + config = {"configurable": {"thread_id": "resume-one"}} + app.invoke(empty_state(), config=config) + app.invoke(Command(resume="yes"), config=config) + + rows = read_events(tmp_path) + roots = [r for r in only(rows, "agent_start") if not r.get("parent_id")] + assert len(roots) == 1, "the resuming .invoke() opened a second root span" + assert len([r for r in only(rows, "agent_end") if r["agent_id"] == "root_graph"]) == 1 + assert types_of(only(rows, "agent_pause", "agent_resume")) == [ + "agent_pause", + "agent_resume", + ] + + +# --------------------------------------------------------------------------- +# A failing root-run-that-is-a-leaf owns its failure exactly once +# --------------------------------------------------------------------------- +# +# `_on_end` returns straight after `_end_root` for a root, so the line at the +# bottom of the function that marks the failure as owned by the span below never +# ran for a root that was ALSO a leaf. The same exception was then reported +# twice — once as `tool_result.error` / `model_response.error` and again as a +# standalone `error` event — and the server derives `is_error` from both, so one +# failure counted as two on `sessionSummary.errorCount`. The identical failure +# one Runnable deeper counted as one. + + +def test_a_failing_bare_tool_reports_its_error_once(tmp_path, instrumented): + with failproofai_sdk.session(): + with pytest.raises(RuntimeError, match="tool boom"): + exploder.invoke({"x": 1}) + + rows = read_events(tmp_path) + result = only(rows, "tool_result")[0] + assert "tool boom" in result["error"] + # The span that owns the failure has reported it; a standalone `error` event + # on top is the same failure counted twice. + assert not only(rows, "error"), ( + "a top-level tool failure was reported both on tool_result and as a " + "standalone error event" + ) + assert only(rows, "agent_end")[0]["outcome"] == "failed" + + +def test_a_failing_bare_model_call_reports_its_error_once(tmp_path, instrumented): + class _BoomModel(GenericFakeChatModel): + def _generate(self, *args, **kwargs): + raise RuntimeError("model boom") + + with failproofai_sdk.session(): + with pytest.raises(RuntimeError, match="model boom"): + _BoomModel(messages=iter([])).invoke("say hi") + + rows = read_events(tmp_path) + response = only(rows, "model_response")[0] + assert "model boom" in response["error"] + assert response["stop_reason"] == "error" + assert not only(rows, "error") + assert only(rows, "agent_end")[0]["outcome"] == "failed" + + +def test_a_failure_below_the_root_still_produces_its_one_error_event( + tmp_path, instrumented +): + """The other side of the same line: a root nothing below reported must still + get exactly one standalone `error`, or the failure reaches no surface.""" + + def boom(_payload): + raise RuntimeError("chain boom") + + with failproofai_sdk.session(): + with pytest.raises(RuntimeError, match="chain boom"): + RunnableLambda(boom).with_config(run_name="boomer").invoke({"x": 1}) + + rows = read_events(tmp_path) + assert len(only(rows, "error")) == 1 + assert only(rows, "agent_end")[0]["outcome"] == "failed" + + +# --------------------------------------------------------------------------- +# `tool_result.output` is the tool's result, not a repr of the envelope +# --------------------------------------------------------------------------- +# +# A tool handed the LLM's `ToolCall` dict — what `bind_tools` produces and what +# every modern tool loop passes — returns a `ToolMessage`. `truncate` has no +# JSON shape for one, so the single most-read field in a tool loop rendered as +# `ToolMessage(content='3', name='adder', tool_call_id='call_zz', ...)`. +# +# `status` is the second half: a `ToolMessage` carries `status="error"` when the +# tool failed but the framework turned the exception into a message for the +# model instead of raising. `run.error` is empty on that path, so the failure +# had no representation at all — `is_error` 0, a green span, and the text of the +# failure sitting in an output field nobody filters on. + + +def test_a_tool_called_with_a_tool_call_records_its_content_not_a_repr( + tmp_path, instrumented +): + with failproofai_sdk.session(): + adder.invoke( + {"name": "adder", "args": {"a": 1, "b": 2}, "id": "call_zz", "type": "tool_call"} + ) + + result = only(read_events(tmp_path), "tool_result")[0] + assert result["output"] == "3", ( + f"tool_result.output is {result['output']!r} — the ToolMessage envelope " + f"leaked instead of the tool's own result" + ) + assert "ToolMessage(" not in str(result["output"]) + assert result["tool_call_id"] == "call_zz" + + +def test_a_tool_that_fails_without_raising_is_still_an_error(tmp_path, instrumented): + @tool + def quiet_failer(x: int) -> str: + """Fails without raising: returns an error-status ToolMessage.""" + return ToolMessage(content="upstream 503", tool_call_id="unused", status="error") + + with failproofai_sdk.session(): + quiet_failer.invoke( + {"name": "quiet_failer", "args": {"x": 1}, "id": "call_q", "type": "tool_call"} + ) + + result = only(read_events(tmp_path), "tool_result")[0] + assert result.get("error"), ( + "a tool that reported failure through ToolMessage(status='error') was " + "recorded as a success" + ) + assert "upstream 503" in result["error"] + + +def test_a_successful_tool_message_carries_no_error(tmp_path, instrumented): + """The `status` read must not turn every ToolMessage into a failure.""" + with failproofai_sdk.session(): + adder.invoke( + {"name": "adder", "args": {"a": 1, "b": 2}, "id": "call_ok", "type": "tool_call"} + ) + assert only(read_events(tmp_path), "tool_result")[0].get("error") is None + + +# --------------------------------------------------------------------------- +# The `error` event does not repeat its own type +# --------------------------------------------------------------------------- +# +# `error` is the one event that carries `error_type` as its OWN field, and the +# server builds the row's `summary` as "<error_type>: <message>". Feeding it +# `_error_text` — which prefixes the type because `tool_result.error` and +# `agent_end.summary` have nowhere else to say it — rendered every entry on the +# Errors surface as `ValueError: ValueError: denominator must be non-zero`. +# CrewAI, LlamaIndex and Pydantic AI all pass a bare `str(exc)` here. + + +def test_the_error_events_message_does_not_repeat_its_own_type(tmp_path, instrumented): + def boom(_payload): + raise RuntimeError("chain boom") + + with failproofai_sdk.session(): + with pytest.raises(RuntimeError, match="chain boom"): + RunnableLambda(boom).with_config(run_name="boomer").invoke({"x": 1}) + + rows = read_events(tmp_path) + event = only(rows, "error")[0] + assert event["error_type"] == "RuntimeError" + assert event["message"] == "chain boom", ( + f"message is {event['message']!r} — the server renders summary as " + f"'<error_type>: <message>', so a prefixed message says it twice" + ) + # Scoped to the `error` event: `agent_end.summary` has no `error_type` + # field beside it, so it keeps naming the exception type itself. + assert only(rows, "agent_end")[0]["summary"] == "RuntimeError: chain boom" + + +# --------------------------------------------------------------------------- +# `uninstrument()` when the trace env var was exported by somebody else +# --------------------------------------------------------------------------- +# +# A configure hook cannot be deregistered, so removal is "make the hook produce +# nothing" — and neither of the two levers `uninstall()` had actually does that +# in every process. Clearing `_HANDLER_VAR` only reaches contexts derived from +# the caller's, and the env var is unset only when `install()` was the one that +# set it (it must not clobber somebody else's environment). Exported by a +# Dockerfile or a CI job, it left `_configure` constructing a live zero-arg +# tracer per callback manager, and a fully torn-down adapter went on recording +# every event forever. + + +def test_uninstrument_stops_recording_when_the_env_var_was_already_set( + tmp_path, monkeypatch +): + monkeypatch.setenv(adapter.ENV_VAR, "1") + failproofai_sdk.instrument("langchain") + app = build_simple([("n", lambda s: {"vals": ["x"]})], name="s") + app.invoke(empty_state(), config={"configurable": {"thread_id": "on"}}) + before = len(read_events(tmp_path)) + assert before + + assert failproofai_sdk.uninstrument("langchain") == ("langchain",) + # Deliberately still set: `install()` did not set it, so `uninstall()` does + # not get to remove it. That is exactly why it cannot be the kill switch. + assert os.environ.get(adapter.ENV_VAR) == "1" + + app.invoke(empty_state(), config={"configurable": {"thread_id": "off"}}) + assert len(read_events(tmp_path)) == before, ( + "the adapter kept recording after uninstrument() because the trace env " + "var was set before instrument() ran" + ) + + +def test_reinstrumenting_after_that_teardown_records_again(tmp_path, monkeypatch): + """The kill switch must be a switch, not a one-way fuse.""" + monkeypatch.setenv(adapter.ENV_VAR, "1") + failproofai_sdk.instrument("langchain") + failproofai_sdk.uninstrument("langchain") + failproofai_sdk.instrument("langchain") + try: + build_simple([("n", lambda s: {"vals": ["x"]})], name="s").invoke( + empty_state(), config={"configurable": {"thread_id": "again"}} + ) + finally: + failproofai_sdk.uninstrument("langchain") + assert types_of(read_events(tmp_path)).count("agent_start") == 1 + + +# --------------------------------------------------------------------------- +# The node key and the run name are BOTH the user's to choose +# --------------------------------------------------------------------------- +# +# `_node_of` matched on `run.name == metadata["langgraph_node"]` alone, and both +# sides of that comparison are strings a user picks. Every inner run of a node +# inherits `langgraph_node`, so the moment an inner run happens to carry the +# node's name it was recorded as a second visit to the node instead of as what +# it is — and the event that was actually worth having never got emitted. +# +# Verified against langgraph 1.2.11: whatever you hand `add_node`, the node's +# OWN run is always a `chain` run tagged `graph:step:N`, and the thing you +# passed runs beneath it tagged `seq:step:N`. Those two facts are the fix. + + +def test_a_node_named_after_its_tool_still_records_the_tool(tmp_path, instrumented): + """`add_node("adder", ToolNode([adder]))` — the obvious naming — used to + delete the tool call: no `tool_use`, no `tool_result`, no `tool_call_id`, + and `/tools` showing the call had never happened.""" + + def plan(state): + return {"messages": [tool_calling_message()], "vals": ["plan"]} + + graph = StateGraph(State) + graph.add_node("plan", plan) + graph.add_node("adder", ToolNode([adder])) # node key == tool name + graph.add_edge(START, "plan") + graph.add_edge("plan", "adder") + graph.add_edge("adder", END) + graph.compile(name="collide").invoke( + empty_state(), config={"configurable": {"thread_id": "tool-collision"}} + ) + + rows = read_events(tmp_path) + tools = only(rows, "tool_use", "tool_result") + assert types_of(tools) == ["tool_use", "tool_result"], ( + "the tool run was misfiled as a second visit to the node of the same name" + ) + assert tools[0]["tool_name"] == tools[1]["tool_name"] == "adder" + assert tools[0]["tool_call_id"] == tools[1]["tool_call_id"] == "call_abc" + assert tools[1]["output"] == "3" + # ...and the node itself is still exactly one hook, not two. + assert [r["hook_name"] for r in only(rows, "hook_triggered")] == ["plan", "adder"] + + +def test_a_node_named_after_its_model_still_records_the_model(tmp_path, instrumented): + """Same collision one run type over: the model name, both token counts and + the latency were dropped while the trace still looked populated.""" + model = fake_model(tool_calling_message()) + + def call(state): + return {"messages": [model.invoke(state["messages"])], "vals": ["x"]} + + # `GenericFakeChatModel`'s run name is its class name. + app = build_simple([("GenericFakeChatModel", call)], name="model-collide") + app.invoke(empty_state(), config={"configurable": {"thread_id": "model-collision"}}) + + rows = read_events(tmp_path) + assert types_of(only(rows, "model_request", "model_response")) == [ + "model_request", + "model_response", + ], "the chat model run was misfiled as a second visit to the node of the same name" + response = only(rows, "model_response")[0] + assert response["input_tokens"] == 11 + assert response["output_tokens"] == 5 + assert isinstance(response["duration_ms"], int) + assert len(only(rows, "hook_triggered")) == 1 + + +def test_an_inner_runnable_sharing_the_node_name_is_not_a_second_visit( + tmp_path, instrumented +): + """`add_node("same", something.with_config(run_name="same"))` produced TWO + `hook_triggered`/`hook_completed` pairs for one visit, doubling that node's + count on `/hooks` and halving its apparent latency.""" + inner = RunnableLambda(lambda state: {"vals": ["same"]}).with_config(run_name="same") + app = build_simple([("same", inner)], name="dup") + app.invoke(empty_state(), config={"configurable": {"thread_id": "dup"}}) + + rows = read_events(tmp_path) + assert types_of(only(rows, "hook_triggered", "hook_completed")) == [ + "hook_triggered", + "hook_completed", + ] + + +def test_the_node_exclusions_do_not_swallow_real_nodes(tmp_path, instrumented): + """The counterweight. Both new conditions are exclusions, and `_node_of` + gates `hook_triggered` AND `_ensure_subgraph_agent` — over-tighten it and + the timeline loses every node and every subgraph agent at once.""" + app = build_graph() + app.invoke(empty_state(), config={"configurable": {"thread_id": "counterweight"}}) + + rows = read_events(tmp_path) + names = [r["hook_name"] for r in only(rows, "hook_triggered")] + # A plain function node, a ToolNode, a compiled subgraph as a node, two + # Send-dispatched copies of one node: all still hooks. + assert names.count("plan") == 1 + assert names.count("tools") == 1 + assert names.count("child") == 1 + assert names.count("worker") == 2 + assert names.count("sub_step") == 1 + # And the subgraph is still a nested agent, which only happens from inside + # the node branch of `_on_start`. + assert [r["agent_id"] for r in only(rows, "agent_start")] == [ + "root_graph", + "root_graph/child", + ] + + +# --------------------------------------------------------------------------- +# A run that merely OVERLAPS a pause is not the approval +# --------------------------------------------------------------------------- + + +def test_an_unrelated_run_during_a_pause_is_not_read_as_the_approval( + tmp_path, instrumented +): + """A HITL turn sits paused on a human for minutes. Any other run carrying + the same session id in that window — a second request on one conversation + id, a background summariser, a different graph entirely — was read as the + answer: it got no `agent_start` of its own, its work was folded into the + paused span, and the adapter emitted `agent_resume` + `human_input` for a + human who had answered nothing. Fabricating an approval is the worst wrong + answer a human-approval product can give.""" + session = {"metadata": {adapter.SESSION_METADATA_KEY: "one-conversation"}} + paused = build_graph(checkpointer=InMemorySaver()) + paused.invoke( + empty_state(), config={"configurable": {"thread_id": "held"}, **session} + ) + assert types_of(only(read_events(tmp_path), "agent_pause")) == ["agent_pause"] + + unrelated = build_simple([("summarise", lambda s: {"vals": ["s"]})], name="other") + unrelated.invoke(empty_state(), config=dict(session)) + + rows = read_events(tmp_path) + assert not only(rows, "agent_resume"), "an unrelated run closed the human's pause" + assert not only(rows, "human_input"), "an approval was recorded that never happened" + # The unrelated run is its own agent, with its own span, not a relabelled + # continuation of the paused one. + assert "other" in [r["agent_id"] for r in only(rows, "agent_start")] + assert "other" in [r["agent_id"] for r in only(rows, "agent_end")] + + +def test_a_none_input_is_still_a_continuation_of_the_pause(tmp_path, instrumented): + """The counterweight for `_is_continuation`: `invoke(None, config)` is + langgraph's other documented way to resume, and narrowing the test to + `Command` alone would split that run in two.""" + app = build_graph(checkpointer=InMemorySaver()) + config = {"configurable": {"thread_id": "none-resume"}} + app.invoke(empty_state(), config=config) + app.invoke(None, config=config) + + rows = read_events(tmp_path) + roots = [r for r in only(rows, "agent_start") if not r.get("parent_id")] + assert len(roots) == 1, "invoke(None) opened a second root span" + # No answer was supplied, so the node interrupts again and the pause + # reopens — but the FIRST one was closed, on the same span. + assert types_of(only(rows, "agent_pause", "agent_resume"))[:3] == [ + "agent_pause", + "agent_resume", + "agent_pause", + ] + + +# --------------------------------------------------------------------------- +# The resume arrives in a DIFFERENT PROCESS +# --------------------------------------------------------------------------- +# +# Every pause above is keyed on the `Interrupt` object the pausing process saw, +# which assumes the process that paused is the process that resumes. Real HITL +# is not shaped like that: one worker serves the request that interrupts, a +# human answers minutes later, and whichever worker picks that request up +# resumes against the shared checkpointer. `_STATE` is per process, so the +# resuming worker had no `_Session` and no `open_pauses` — it emitted no +# `agent_resume` and no `human_input` at all, and the `human_wait` / +# `agent_pause` from the first worker stayed open FOREVER. Every cross-process +# approval left its session reporting "still waiting on a human" after the human +# had answered. +# +# `_STATE.reset()` between the two `.invoke()` calls is exactly a fresh process: +# it is the same clear the adapter does at `install()`, and the events the first +# "process" wrote are already on disk. + + +def _forget_everything_this_process_knows(): + adapter._STATE.reset() + + +def test_a_resume_from_another_process_still_closes_the_pause(tmp_path, instrumented): + saver = InMemorySaver() + config = {"configurable": {"thread_id": "xproc"}} + + build_graph(checkpointer=saver).invoke(empty_state(), config=config) + opened = only(read_events(tmp_path), "human_wait") + assert len(opened) == 1 + + _forget_everything_this_process_knows() + build_graph(checkpointer=saver).invoke(Command(resume="approved"), config=config) + + rows = read_events(tmp_path) + resumes = only(rows, "agent_resume") + answers = only(rows, "human_input") + assert len(resumes) == 1 and len(answers) == 1, ( + "the pause opened by the other process was never closed" + ) + # Correlated on the id the FIRST process reported, with no shared state: + # langgraph derives `Interrupt.id` from the interrupted task's checkpoint + # namespace, which is byte-identical across the two invocations. + assert resumes[0]["pause_id"] == opened[0]["input_id"] + assert answers[0]["input_id"] == opened[0]["input_id"] + assert answers[0]["response"] == "approved" + + +def test_a_remote_resume_credits_the_interrupted_node_not_the_subgraph_host( + tmp_path, instrumented +): + """The subgraph host node re-runs too, at a shallower namespace, and it + starts *before* the lifecycle event that unmasks it. Crediting the pause to + it would emit an id that correlates with nothing and leave the real pause + open — the exact failure being fixed, wearing a fix.""" + saver = InMemorySaver() + config = {"configurable": {"thread_id": "xproc-sub"}} + + def build(): + inner = StateGraph(State) + inner.add_node("sub_pre", lambda state: {"vals": ["pre"]}) + inner.add_node("ask", lambda state: {"answer": str(interrupt({"prompt": "ok?"}))}) + inner.add_edge(START, "sub_pre") + inner.add_edge("sub_pre", "ask") + inner.add_edge("ask", END) + outer = StateGraph(State) + outer.add_node("before", lambda state: {"vals": ["b"]}) + outer.add_node("child", inner.compile(name="child_graph")) + outer.add_node("after", lambda state: {"vals": ["a"]}) + outer.add_edge(START, "before") + outer.add_edge("before", "child") + outer.add_edge("child", "after") + outer.add_edge("after", END) + return outer.compile(name="root_graph", checkpointer=saver) + + build().invoke(empty_state(), config=config) + opened = only(read_events(tmp_path), "human_wait") + assert len(opened) == 1 + + _forget_everything_this_process_knows() + build().invoke(Command(resume="yes"), config=config) + + rows = read_events(tmp_path) + # Exactly one pair — not one per node that re-ran, and not one for the + # subgraph host. + assert len(only(rows, "agent_resume")) == 1 + assert len(only(rows, "human_input")) == 1 + assert only(rows, "agent_resume")[0]["pause_id"] == opened[0]["input_id"] + + +def test_a_remote_resume_invents_no_pause_for_downstream_nodes(tmp_path, instrumented): + """Only the level's FIRST superstep re-runs interrupted tasks; every node + after it is ordinary downstream work at the same namespace depth. Without + that guard the resumed run manufactures one `agent_resume` + `human_input` + per node it visits, each with an id that matches no pause — and the real + pause still never closes.""" + saver = InMemorySaver() + config = {"configurable": {"thread_id": "xproc-downstream"}} + + def build(): + return build_simple( + [ + ("ask", lambda state: {"answer": str(interrupt({"prompt": "ok?"}))}), + ("after_one", lambda state: {"vals": ["a"]}), + ("after_two", lambda state: {"vals": ["b"]}), + ], + name="downstream", + checkpointer=saver, + ) + + build().invoke(empty_state(), config=config) + opened = only(read_events(tmp_path), "human_wait") + assert len(opened) == 1 + + _forget_everything_this_process_knows() + build().invoke(Command(resume="yes"), config=config) + + rows = read_events(tmp_path) + assert [r["hook_name"] for r in only(rows, "hook_triggered")][1:] == [ + "ask", + "after_one", + "after_two", + ] + assert len(only(rows, "agent_resume")) == 1 + assert len(only(rows, "human_input")) == 1 + assert only(rows, "agent_resume")[0]["pause_id"] == opened[0]["input_id"] + assert len(only(rows, "human_wait")) == 1 + assert not only(rows, "error") + + +def test_a_fresh_turn_in_a_fresh_process_is_not_a_remote_resume(tmp_path, instrumented): + """The counterweight. `_RemoteResume` is armed off the root input alone, so + a graph that simply runs — no interrupt anywhere, no `Command` — must never + manufacture a resume for a human who was never asked.""" + build_graph().invoke(empty_state(), config={"configurable": {"thread_id": "plain"}}) + rows = read_events(tmp_path) + assert not only(rows, "agent_resume") + assert not only(rows, "human_input") + + +def test_a_remote_rerun_with_no_answer_records_no_approval(tmp_path, instrumented): + """The sharp counterweight for the arming condition. + + `invoke(None, config)` re-runs an interrupted thread WITHOUT answering it — + the task interrupts again and no human said anything. Widening the arming + test from "a `Command` carrying a resume value" to "anything that continues + a thread" would record an approval, with an empty response, for a human who + is still waiting. The pause must simply stay open.""" + saver = InMemorySaver() + config = {"configurable": {"thread_id": "xproc-noanswer"}} + build_graph(checkpointer=saver).invoke(empty_state(), config=config) + + _forget_everything_this_process_knows() + build_graph(checkpointer=saver).invoke(None, config=config) + + rows = read_events(tmp_path) + assert not only(rows, "agent_resume"), "an approval was recorded for a re-run" + assert not only(rows, "human_input") + # Still waiting, and said so twice — once per attempt. + assert len(only(rows, "human_wait")) == 2 + + +def test_an_in_process_resume_emits_exactly_one_resume_pair(tmp_path, instrumented): + """The other counterweight: the remote path must stay inert whenever this + process owns the pause, or every ordinary approval is recorded twice.""" + app = build_graph(checkpointer=InMemorySaver()) + config = {"configurable": {"thread_id": "in-process"}} + app.invoke(empty_state(), config=config) + app.invoke(Command(resume="yes"), config=config) + + rows = read_events(tmp_path) + assert len(only(rows, "agent_resume")) == 1 + assert len(only(rows, "human_input")) == 1 + assert only(rows, "agent_resume")[0].get("fw_resumed_elsewhere") is None + + +def test_the_interrupt_id_is_still_derived_from_the_checkpoint_namespace(): + """The whole cross-process fix rests on one langgraph invariant: an + interrupt's id is `xxh3_128` of the interrupted task's checkpoint namespace, + not a random value. If upstream makes it random, `_close_remote_pause` goes + on emitting ids that correlate with nothing and every behavioural test above + keeps passing, because they all compare our derived id against itself.""" + from langgraph.types import Interrupt + + ns = "ask:0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0" + assert Interrupt.from_ns("value", ns).id == adapter._interrupt_id_of(ns) + assert Interrupt.from_ns("a different value", ns).id == adapter._interrupt_id_of(ns) + assert adapter._interrupt_id_of(ns) != adapter._interrupt_id_of(ns + "x") + + +def test_a_subgraph_compiled_under_its_node_name_is_one_hook_not_two( + tmp_path, instrumented +): + """`sub.compile(name="child")` added as `add_node("child", sub)` is the + natural way to name a subgraph, and it makes the subgraph's own Pregel run + match the node too. That produced a duplicate `hook_triggered` for the node + AND turned the Pregel run into a node, which then also opened and closed a + nested agent on the same run — one visit rendering as four spans.""" + inner = StateGraph(State) + inner.add_node("deep", lambda state: {"vals": ["deep"]}) + inner.add_edge(START, "deep") + inner.add_edge("deep", END) + + outer = StateGraph(State) + outer.add_node("child", inner.compile(name="child")) + outer.add_edge(START, "child") + outer.add_edge("child", END) + outer.compile(name="root_graph").invoke( + empty_state(), config={"configurable": {"thread_id": "sub-name"}} + ) + + rows = read_events(tmp_path) + assert [r["hook_name"] for r in only(rows, "hook_triggered")] == ["child", "deep"] + assert [r["hook_name"] for r in only(rows, "hook_completed")] == ["deep", "child"] + assert [r["agent_id"] for r in only(rows, "agent_start")] == [ + "root_graph", + "root_graph/child", + ] + + +def test_a_leaf_run_is_never_the_nodes_own_run(tmp_path): + """The second exclusion, pinned directly. + + langgraph 1.2.11 happens to tag the inner run `seq:step:N` as well, so the + behavioural tests above would still pass with this condition removed — and + that is exactly why it is here. It states the invariant that does not depend + on a tag convention: whatever you hand `add_node`, the node's OWN run is the + `chain` run langgraph builds around it, and a `tool` / `llm` / `chat_model` / + `retriever` run carrying the node's name is the thing you passed, running + underneath. Lose this and a tag rename silently deletes tool and model + events again, which is a wrong answer with no symptom. + """ + import types as _types + + meta = {"langgraph_node": "adder"} + + def run(run_type, tags=()): + return _types.SimpleNamespace(name="adder", run_type=run_type, tags=list(tags)) + + assert adapter._node_of(run("chain"), meta) == "adder" + for leaf in ("tool", "llm", "chat_model", "retriever"): + assert adapter._node_of(run(leaf), meta) is None, ( + f"a {leaf} run named after its node was claimed as the node itself" + ) + + +def test_an_overlapping_run_cannot_strand_the_pause_it_did_not_answer( + tmp_path, instrumented +): + """The two fixes above meeting in the shape that produced both. + + `_State.sessions` is keyed by session id, so a second root under the same id + overwrites the paused run's entry and then pops it on the way out. The real + approval, arriving afterwards, finds nothing — which is the same position a + fresh worker is in, and is why the remote path is the backstop rather than a + special case. The pause must still close, on the id the human was asked + under, carrying what they actually said.""" + session = {"metadata": {adapter.SESSION_METADATA_KEY: "shared-conversation"}} + app = build_graph(checkpointer=InMemorySaver()) + config = {"configurable": {"thread_id": "stranded"}, **session} + + app.invoke(empty_state(), config=config) + opened = only(read_events(tmp_path), "human_wait") + assert len(opened) == 1 + + build_simple([("other", lambda state: {"vals": ["o"]})], name="other").invoke( + empty_state(), config=dict(session) + ) + app.invoke(Command(resume="approved"), config=config) + + rows = read_events(tmp_path) + assert {r["session_id"] for r in rows} == {"shared-conversation"} + assert len(only(rows, "agent_resume")) == 1 + answers = only(rows, "human_input") + assert len(answers) == 1 + assert answers[0]["input_id"] == opened[0]["input_id"] + assert answers[0]["response"] == "approved" diff --git a/sdk/python/tests/integrations/test_llama_index.py b/sdk/python/tests/integrations/test_llama_index.py new file mode 100644 index 000000000..1a0ce3285 --- /dev/null +++ b/sdk/python/tests/integrations/test_llama_index.py @@ -0,0 +1,1492 @@ +"""The LlamaIndex adapter, against the real framework and a stub model. + +No network, no API key, no mock of our own code: every assertion below reads +the **JSONL the writer actually produced** after driving a real +`FunctionAgent` / `Workflow`. Asserting on mock call args would happily pass +against an adapter that emits nothing the ingest pipeline can use. + +The single highest-value test in the file is +`test_our_overrides_still_exist_on_the_framework_base_classes`. Every other +test here can stay green while the adapter is completely dead: if upstream +renames `prepare_to_exit_span`, our override is never called, the span handler +records nothing, and a fake-based suite notices nothing at all. +""" + +import asyncio +import inspect +import json +import os +from dataclasses import fields as dataclass_fields +from typing import Any, List, Sequence + +import pytest + +import failproofai_sdk +from failproofai_sdk import _runtime, _schema +from failproofai_sdk.integrations import _compat, _core + +# `pytest.importorskip` is fail-open: misspell the module and every leg skips +# while CI stays green having tested nothing. The framework CI leg sets +# AGENTEYE_TESTS_REQUIRE_FRAMEWORKS=1, which turns the skip into a hard error. +if os.environ.get("AGENTEYE_TESTS_REQUIRE_FRAMEWORKS", "").strip().lower() in {"1", "true", "yes"}: + import llama_index.core # noqa: F401 +else: + pytest.importorskip("llama_index.core", reason="llama-index-core is not installed") + +from llama_index.core.base.llms.types import ( # noqa: E402 + ChatMessage, + ChatResponse, + ChatResponseAsyncGen, + ChatResponseGen, + CompletionResponse, + CompletionResponseAsyncGen, + CompletionResponseGen, + LLMMetadata, + MessageRole, +) +from llama_index.core.llms.callbacks import llm_chat_callback, llm_completion_callback # noqa: E402 +from llama_index.core.llms.function_calling import FunctionCallingLLM # noqa: E402 +from llama_index.core.tools import ToolSelection # noqa: E402 +from llama_index.core.tools.types import BaseTool # noqa: E402 +from pydantic import Field, PrivateAttr # noqa: E402 + +from failproofai_sdk.integrations import llama_index as adapter_module # noqa: E402 + +pytestmark = pytest.mark.framework + + +# --------------------------------------------------------------------------- +# A stub function-calling model. Real FunctionAgent, fake LLM. +# --------------------------------------------------------------------------- + +class StubLLM(FunctionCallingLLM): + """Replays a script of tool calls, then answers. Never touches a network.""" + + model: str = "stub-model-1" + script: List[Any] = Field(default_factory=list) + final: str = "done" + _turn: int = PrivateAttr(default=0) + + @property + def metadata(self) -> LLMMetadata: + return LLMMetadata( + model_name=self.model, is_chat_model=True, is_function_calling_model=True + ) + + def _next(self) -> ChatResponse: + turn = self._turn + self._turn += 1 + if turn < len(self.script): + name, kwargs = self.script[turn] + message = ChatMessage( + role=MessageRole.ASSISTANT, + content="", + additional_kwargs={ + "tool_calls": [{"id": f"call_{turn}", "name": name, "kwargs": kwargs}] + }, + ) + else: + message = ChatMessage(role=MessageRole.ASSISTANT, content=self.final) + return ChatResponse( + message=message, + raw={"usage": {"prompt_tokens": 11, "completion_tokens": 7, "total_tokens": 18}}, + ) + + @llm_chat_callback() + def chat(self, messages: Sequence[ChatMessage], **kwargs: Any) -> ChatResponse: + return self._next() + + @llm_chat_callback() + async def achat(self, messages: Sequence[ChatMessage], **kwargs: Any) -> ChatResponse: + return self._next() + + @llm_chat_callback() + def stream_chat(self, messages: Sequence[ChatMessage], **kwargs: Any) -> ChatResponseGen: + def gen(): + yield self._next() + + return gen() + + @llm_chat_callback() + async def astream_chat( + self, messages: Sequence[ChatMessage], **kwargs: Any + ) -> ChatResponseAsyncGen: + async def gen(): + yield self._next() + + return gen() + + @llm_completion_callback() + def complete(self, prompt: str, formatted: bool = False, **kwargs: Any) -> CompletionResponse: + return CompletionResponse(text=self.final) + + @llm_completion_callback() + async def acomplete( + self, prompt: str, formatted: bool = False, **kwargs: Any + ) -> CompletionResponse: + return CompletionResponse(text=self.final) + + @llm_completion_callback() + def stream_complete( + self, prompt: str, formatted: bool = False, **kwargs: Any + ) -> CompletionResponseGen: + def gen(): + yield CompletionResponse(text=self.final, delta=self.final) + + return gen() + + @llm_completion_callback() + async def astream_complete( + self, prompt: str, formatted: bool = False, **kwargs: Any + ) -> CompletionResponseAsyncGen: + async def gen(): + yield CompletionResponse(text=self.final, delta=self.final) + + return gen() + + def _prepare_chat_with_tools( + self, + tools: Sequence["BaseTool"], + user_msg: Any = None, + chat_history: Any = None, + verbose: bool = False, + allow_parallel_tool_calls: bool = False, + tool_required: bool = False, + **kwargs: Any, + ) -> dict: + messages = list(chat_history or []) + if user_msg is not None: + messages.append( + ChatMessage(role=MessageRole.USER, content=user_msg) + if isinstance(user_msg, str) + else user_msg + ) + return {"messages": messages, **kwargs} + + def get_tool_calls_from_response( + self, response: ChatResponse, error_on_no_tool_call: bool = True, **kwargs: Any + ) -> List[ToolSelection]: + calls = response.message.additional_kwargs.get("tool_calls", []) + if not calls and error_on_no_tool_call: + raise ValueError("no tool calls") + return [ + ToolSelection(tool_id=c["id"], tool_name=c["name"], tool_kwargs=c["kwargs"]) + for c in calls + ] + + +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + +def boom(x: int) -> int: + """Always raises.""" + raise RuntimeError("tool exploded") + + +async def _await(awaitable): + return await awaitable + + +def drive(coro_factory): + """Run a workflow from sync test code. + + `Workflow.run()` schedules tasks eagerly, so it must be *called* with a + loop already running — `asyncio.run(workflow.run())` raises + "no running event loop" before the adapter ever sees anything. + """ + + async def _main(): + return await coro_factory() + + return asyncio.run(_main()) + + +def run_agent(agent, prompt: str) -> str: + return str(drive(lambda: agent.run(prompt))) + + +def run_workflow(workflow, **kwargs) -> str: + return str(drive(lambda: workflow.run(**kwargs))) + + +def calculator(llm, **kwargs): + from llama_index.core.agent.workflow import FunctionAgent + + kwargs.setdefault("tools", [add]) + kwargs.setdefault("streaming", False) + return FunctionAgent(name="calc", description="does math", llm=llm, **kwargs) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture() +def instrumented(tmp_path): + """Instrument for one test, then put everything back. + + The flush interval is enormous on purpose: the writer names files with + millisecond resolution, so two automatic flushes inside the same + millisecond overwrite each other. Tests flush once, explicitly, at the end. + """ + _core.set_strict(False) + _compat.set_strict_integrations(False) + _core.reset_failures() + _runtime.writer.set_flush_interval(3600) + assert failproofai_sdk.instrument("llama_index") == ("llama_index",) + try: + yield adapter_module.adapter + finally: + failproofai_sdk.uninstrument("llama_index") + _core.set_strict(None) + _compat.set_strict_integrations(None) + _core.reset_failures() + + +@pytest.fixture() +def instrumented_without_steps(tmp_path): + """`instrumented`, with the workflow-step hooks switched off. + + Spelled out rather than parameterising the fixture above: `instrumented` is + used by nearly every test in this file, and a signature change there is a + change to all of them. + """ + _core.set_strict(False) + _compat.set_strict_integrations(False) + _core.reset_failures() + _runtime.writer.set_flush_interval(3600) + assert failproofai_sdk.instrument("llama_index", steps=False) == ("llama_index",) + try: + yield adapter_module.adapter + finally: + failproofai_sdk.uninstrument("llama_index") + _core.set_strict(None) + _compat.set_strict_integrations(None) + _core.reset_failures() + + +@pytest.fixture(autouse=True) +def _isolate_writer_queue(): + """Start and end every test with an empty writer queue. + + The queue is process-global while `base_dir` is per-test, so events a test + never flushed are written into the NEXT test's directory the first time + anything flushes — which reads as a mystery second session id and is + exactly the kind of cross-test bleed this suite exists to rule out. + """ + _runtime.writer._queue.clear() + yield + _runtime.writer._queue.clear() + + +@pytest.fixture(autouse=True) +def _no_silent_adapter_failure(request): + """Fail the test if the adapter swallowed an exception. + + `safe()` exists so a bug costs one log line instead of the process — which + also means a broken adapter passes every behavioural test that only checks + "the run finished". This makes that invisible failure visible in the suite. + """ + yield + if _EXPECTS_FAILURE in request.node.name: + return + assert not _core._disabled, f"adapter self-disabled a call site: {_core._disabled}" + assert not _core._failures, f"adapter swallowed exceptions: {_core._failures}" + + +# pyproject sets `--strict-markers`, so a bespoke marker is not available here: +# the two tests that deliberately make the adapter fail are named instead. +_EXPECTS_FAILURE = "raises_on_every_call" + + +def read_events(tmp_path) -> list[dict]: + _runtime.writer.flush_now() + rows: list[dict] = [] + for path in sorted((tmp_path / "events").glob("*.jsonl")): + rows.extend(json.loads(line) for line in path.read_text().splitlines() if line) + return rows + + +def types_of(events: list[dict]) -> list[str]: + return [event["type"] for event in events] + + +def only(events: list[dict], kind: str) -> list[dict]: + return [event for event in events if event["type"] == kind] + + +# The exact sequence a one-tool FunctionAgent turn produces. Written out rather +# than computed: a change here should require a human to look at it. +ONE_TOOL_SEQUENCE = [ + "agent_start", + "hook_triggered", # init_run + "hook_completed", + "hook_triggered", # setup_agent + "hook_completed", + "hook_triggered", # run_agent_step + "model_request", + "model_response", + "hook_completed", + "hook_triggered", # parse_agent_output + "hook_completed", + "hook_triggered", # call_tool + "tool_use", + "tool_result", + "hook_completed", + "hook_triggered", # aggregate_tool_results + "hook_completed", + "hook_triggered", # setup_agent + "hook_completed", + "hook_triggered", # run_agent_step + "model_request", + "model_response", + "hook_completed", + "hook_triggered", # parse_agent_output + "hook_completed", + "agent_end", +] + + +# --------------------------------------------------------------------------- +# The representative run +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("streaming", [False, True], ids=["blocking", "streaming"]) +def test_exact_event_sequence_for_a_one_tool_run(instrumented, tmp_path, streaming): + llm = StubLLM(script=[("add", {"a": 2, "b": 3})], final="5") + agent = calculator(llm, streaming=streaming) + assert str(run_agent(agent, "what is 2+3?")) == "5" + + events = read_events(tmp_path) + # Streaming produces the same shape: the model span exits the instant the + # generator is created, so the response is parked and closed by + # LLMChatEndEvent rather than by the span. + assert types_of(events) == ONE_TOOL_SEQUENCE + + +def test_the_root_agent_start_is_the_sessions_first_event(instrumented, tmp_path): + llm = StubLLM(script=[("add", {"a": 1, "b": 1})], final="2") + run_agent(calculator(llm), "1+1?") + + events = read_events(tmp_path) + # `agent_sessions.agent_id = any(...)` over an ORDER BY (session_id, ts) + # table returns the FIRST agent_id by time. If anything preceded the root + # agent_start the sessions list would name a workflow step instead. + assert events[0]["type"] == "agent_start" + assert events[0]["agent_id"] == "calc" + assert {event["session_id"] for event in events} == {events[0]["session_id"]} + + +def test_every_event_carries_the_framework_triple(instrumented, tmp_path): + llm = StubLLM(script=[("add", {"a": 1, "b": 1})], final="2") + run_agent(calculator(llm), "1+1?") + + for event in read_events(tmp_path): + assert event["framework"] == "llama_index" + assert event["framework_version"] + assert event["integration_version"] + + +def test_agent_ids_are_readable_names_not_span_ids(instrumented, tmp_path): + llm = StubLLM(script=[("add", {"a": 1, "b": 1})], final="2") + run_agent(calculator(llm), "1+1?") + + for event in read_events(tmp_path): + agent_id = event["agent_id"] + # agent_id is LowCardinality(String) and the global dashboard facet; a + # span id in it fills the filter dropdown with one entry per run. + assert agent_id == "calc" + assert "-" not in agent_id and len(agent_id) < 32 + + +def test_model_requests_and_responses_pair_on_request_id(instrumented, tmp_path): + llm = StubLLM(script=[("add", {"a": 2, "b": 3})], final="5") + run_agent(calculator(llm), "2+3?") + + events = read_events(tmp_path) + requests = only(events, "model_request") + responses = only(events, "model_response") + assert len(requests) == len(responses) == 2 + assert [r["request_id"] for r in requests] == [r["request_id"] for r in responses] + assert len({r["request_id"] for r in requests}) == 2 + + for response in responses: + # Invariant 3: an int, always. The server stores duration_ms as a u32 + # and its JSON parser drops floats, so a float silently NULLs it. + assert isinstance(response["duration_ms"], int) + assert not isinstance(response["duration_ms"], bool) + assert response["model"] == "stub-model-1" + + +def test_model_name_comes_from_metadata_not_the_gutted_model_dict(instrumented, tmp_path): + llm = StubLLM(script=[], final="hi") + run_agent(calculator(llm), "hi") + + events = read_events(tmp_path) + assert {e["model"] for e in only(events, "model_request")} == {"stub-model-1"} + # `to_payload()` replaced `to_dict()` in 0.14.23 and the "model" key is gone. + start_payload = llm.to_payload() + assert "model" not in start_payload + + +def test_tokens_are_extracted_and_the_raw_usage_dict_ships_too(instrumented, tmp_path): + llm = StubLLM(script=[], final="hi") + run_agent(calculator(llm), "hi") + + response = only(read_events(tmp_path), "model_response")[0] + assert response["input_tokens"] == 11 + assert response["output_tokens"] == 7 + # Both event_summary.rs and sessionSummary.ts fall back to `usage`. + assert response["usage"]["total_tokens"] == 18 + + +def test_tool_use_and_tool_result_pair_and_carry_a_duration(instrumented, tmp_path): + llm = StubLLM(script=[("add", {"a": 2, "b": 3})], final="5") + run_agent(calculator(llm), "2+3?") + + events = read_events(tmp_path) + uses = only(events, "tool_use") + results = only(events, "tool_result") + assert len(uses) == len(results) == 1 + assert uses[0]["tool_call_id"] == results[0]["tool_call_id"] == "call_0" + assert uses[0]["tool_name"] == results[0]["tool_name"] == "add" + assert uses[0]["input"] == {"a": 2, "b": 3} + assert results[0]["output"] == "5" + assert isinstance(results[0]["duration_ms"], int) + + +def test_tool_call_id_is_the_frameworks_own_id(instrumented, tmp_path): + llm = StubLLM(script=[("add", {"a": 2, "b": 3})], final="5") + run_agent(calculator(llm), "2+3?") + + use = only(read_events(tmp_path), "tool_use")[0] + # Passing the framework's id through verbatim is what makes our events line + # up with the customer's provider logs. + assert use["tool_call_id"] == "call_0" + assert use["fw_tool_id"] == "call_0" + + +def test_workflow_steps_become_hooks_not_agents(instrumented, tmp_path): + llm = StubLLM(script=[("add", {"a": 2, "b": 3})], final="5") + run_agent(calculator(llm), "2+3?") + + events = read_events(tmp_path) + hooks = only(events, "hook_triggered") + names = [hook["hook_name"] for hook in hooks] + assert names[:3] == ["init_run", "setup_agent", "run_agent_step"] + assert all(hook["trigger_event"] == "workflow_step" for hook in hooks) + # Steps as agents would drown the agent_id facet with `parse_agent_output`. + assert {e["agent_id"] for e in events} == {"calc"} + for completed in only(events, "hook_completed"): + assert isinstance(completed["duration_ms"], int) + + +# --------------------------------------------------------------------------- +# Rendering invariants +# --------------------------------------------------------------------------- + +def assert_rendering_invariants(events: list[dict]) -> None: + """The four properties that make the dashboard draw the session correctly.""" + assert events, "no events at all" + assert events[0]["type"] == "agent_start", "invariant 2: root agent_start must be first" + + open_agents: dict[str, int] = {} + open_leaves: dict[tuple[str, str], int] = {} + for event in events: + kind = event["type"] + agent_id = event["agent_id"] + if kind == "agent_start": + open_agents[agent_id] = open_agents.get(agent_id, 0) + 1 + else: + # Invariant 1: an event under an agent_id with no open agent_start + # makes executionGraph synthesize a root span that stays `ongoing`. + assert open_agents.get(agent_id, 0) > 0, f"{kind} under a closed agent {agent_id!r}" + if kind == "agent_end": + open_agents[agent_id] -= 1 + elif kind == "tool_use": + open_leaves[("tool", event["tool_call_id"])] = 1 + elif kind == "tool_result": + tool_leaf = open_leaves.pop(("tool", event["tool_call_id"]), None) + assert tool_leaf, "unpaired tool_result" + elif kind == "model_request": + open_leaves[("model", event["request_id"])] = 1 + elif kind == "model_response": + model_leaf = open_leaves.pop(("model", event["request_id"]), None) + assert model_leaf, "unpaired model_response" + assert isinstance(event["duration_ms"], int), "invariant 3" + elif kind == "hook_triggered": + open_leaves[("hook", event["hook_id"])] = 1 + elif kind == "hook_completed": + hook_leaf = open_leaves.pop(("hook", event["hook_id"]), None) + assert hook_leaf, "unpaired hook_completed" + + assert not any(open_agents.values()), f"agents left open: {open_agents}" + # Invariant 4: agent_end force-closes open pauses but NOT tools or models, + # so a leaf left open renders the session `ongoing` forever. + assert not open_leaves, f"leaves left open: {sorted(open_leaves)}" + + +@pytest.mark.parametrize("streaming", [False, True], ids=["blocking", "streaming"]) +def test_rendering_invariants_hold_for_a_normal_run(instrumented, tmp_path, streaming): + llm = StubLLM(script=[("add", {"a": 2, "b": 3})], final="5") + run_agent(calculator(llm, streaming=streaming), "2+3?") + assert_rendering_invariants(read_events(tmp_path)) + + +def test_rendering_invariants_hold_when_a_tool_fails(instrumented, tmp_path): + llm = StubLLM(script=[("boom", {"x": 1})], final="recovered") + agent = calculator(llm, tools=[boom]) + run_agent(agent, "blow up") + assert_rendering_invariants(read_events(tmp_path)) + + +# --------------------------------------------------------------------------- +# Failure paths +# --------------------------------------------------------------------------- + +def test_a_failing_tool_is_reported_on_the_tool_and_not_double_counted(instrumented, tmp_path): + llm = StubLLM(script=[("boom", {"x": 1})], final="recovered") + result = run_agent(calculator(llm, tools=[boom]), "blow up") + assert str(result) == "recovered" + + events = read_events(tmp_path) + result_event = only(events, "tool_result")[0] + assert result_event["error"] == "RuntimeError: tool exploded" + # The span that owns the failure reports it. A standalone `error` event + # would double-count on sessionSummary.errorCount. + assert only(events, "error") == [] + # The agent recovered, so the run did not fail. + assert only(events, "agent_end")[0]["outcome"] == "success" + + +def test_a_failing_workflow_step_reports_once_and_fails_the_agent(instrumented, tmp_path): + from llama_index.core.workflow import StartEvent, StopEvent, Workflow, step + + class Bad(Workflow): + @step + async def go(self, ev: StartEvent) -> StopEvent: + raise ValueError("workflow blew up") + + with pytest.raises(ValueError): + run_workflow(Bad(timeout=5)) + + events = read_events(tmp_path) + completed = only(events, "hook_completed")[0] + assert completed["outcome"] == "failed" + assert completed["error"] == "ValueError: workflow blew up" + assert only(events, "error") == [], "the step owns this failure; do not count it twice" + assert only(events, "agent_end")[0]["outcome"] == "failed" + assert_rendering_invariants(events) + + +def test_a_run_level_failure_nobody_owns_gets_one_error_event(instrumented, tmp_path): + from llama_index.core.workflow import StartEvent, StopEvent, Workflow, step + + class Slow(Workflow): + @step + async def go(self, ev: StartEvent) -> StopEvent: + await asyncio.sleep(5) + return StopEvent(result="never") + + with pytest.raises(Exception): + run_workflow(Slow(timeout=0.3)) + + events = read_events(tmp_path) + errors = only(events, "error") + # Nothing below the run reported the timeout, so exactly one standalone + # `error` carries it — and it comes strictly BEFORE agent_end, because the + # graph closes the agent span at agent_end and an error after it is + # attributed to nothing. + assert len(errors) == 1 + assert errors[0]["error_type"] == "WorkflowTimeoutError" + assert types_of(events).index("error") < types_of(events).index("agent_end") + assert only(events, "agent_end")[0]["outcome"] == "failed" + assert_rendering_invariants(events) + + +def test_a_failed_agent_end_says_what_killed_the_run(instrumented, tmp_path): + """`outcome="failed"` is not a reason, and `summary` is a promoted column. + + The failing step's `hook_completed` does carry the error, but that is + payload-only — and with `steps=False` it is not emitted at all, which leaves + a failed run with its cause recorded precisely nowhere. + """ + from llama_index.core.workflow import StartEvent, StopEvent, Workflow, step + + class Bad(Workflow): + @step + async def go(self, ev: StartEvent) -> StopEvent: + raise ValueError("workflow blew up") + + with pytest.raises(ValueError): + run_workflow(Bad(timeout=5)) + + end = only(read_events(tmp_path), "agent_end")[0] + assert end["outcome"] == "failed" + assert end["summary"] == "ValueError: workflow blew up" + + +def test_a_timed_out_agent_end_says_what_killed_the_run(instrumented, tmp_path): + """Same promise on the other failure path, where there IS an error event.""" + from llama_index.core.workflow import StartEvent, StopEvent, Workflow, step + + class Slow(Workflow): + @step + async def go(self, ev: StartEvent) -> StopEvent: + await asyncio.sleep(5) + return StopEvent(result="never") + + with pytest.raises(Exception): + run_workflow(Slow(timeout=0.3)) + + end = only(read_events(tmp_path), "agent_end")[0] + assert end["outcome"] == "failed" + assert end["summary"].startswith("WorkflowTimeoutError:") + + +def test_a_translator_that_raises_on_every_call_does_not_break_the_run( + instrumented, tmp_path, monkeypatch, caplog +): + """The whole failure policy, proved rather than asserted. + + This also covers the LlamaIndex-specific hazard: the dispatcher wraps every + handler call in `except BaseException: pass` **with no logging**, so an + adapter bug is invisible unless we log it ourselves. + """ + state = instrumented.state + + def explode(*args, **kwargs): + raise RuntimeError("translator is broken") + + monkeypatch.setattr(type(state), "span_enter", explode) + monkeypatch.setattr(type(state), "span_exit", explode) + monkeypatch.setattr(type(state), "span_drop", explode) + monkeypatch.setattr(type(state), "model_start", explode) + monkeypatch.setattr(type(state), "model_end", explode) + + caplog.set_level("WARNING", logger="failproofai_sdk.integrations") + llm = StubLLM(script=[("add", {"a": 2, "b": 3})], final="5") + assert str(run_agent(calculator(llm), "2+3?")) == "5" + + logged = " ".join(record.getMessage() for record in caplog.records) + # BOTH handlers, named individually: the span handler alone satisfying this + # would let an unwrapped `handle()` through, and the event handler is where + # every model event comes from. + # Match the qualname exactly: `.handle` alone is a substring of + # `.handler_classes`, which appears in every site name in this module. + assert "FailproofAISpanHandler.new_span" in logged, ( + "the span handler swallowed its exception silently" + ) + assert "FailproofAIEventHandler.handle" in logged, ( + "the EVENT handler swallowed its exception without logging — the " + "dispatcher already does that for us, with no traceback and no name" + ) + _core.reset_failures() + + +def test_strict_mode_still_cannot_take_the_run_down(instrumented, tmp_path, monkeypatch): + """FAILPROOFAI_SDK_STRICT re-raises out of `safe()` — and the dispatcher eats it. + + Worth pinning: strict mode is a debugging switch, not a way to make an + instrumented LlamaIndex application fail fast. Anyone reaching for it to + "make errors loud in prod" should read this test first. + """ + state = instrumented.state + _core.set_strict(True) + + def explode(*args, **kwargs): + raise RuntimeError("translator is broken") + + monkeypatch.setattr(type(state), "span_enter", explode) + llm = StubLLM(script=[], final="ok") + assert str(run_agent(calculator(llm), "hi")) == "ok" + + +# --------------------------------------------------------------------------- +# Human in the loop +# --------------------------------------------------------------------------- + +def test_human_in_the_loop_emits_both_pairs(instrumented, tmp_path): + from llama_index.core.workflow import Context, HumanResponseEvent, InputRequiredEvent + + async def ask_human(ctx: Context, question: str) -> str: + """Ask the human a question.""" + answer = await ctx.wait_for_event( + HumanResponseEvent, + waiter_id="ask", + waiter_event=InputRequiredEvent(prefix=question), + ) + return answer.response + + async def converse() -> str: + llm = StubLLM(script=[("ask_human", {"question": "ok?"})], final="the human said yes") + agent = calculator(llm, tools=[ask_human]) + handler = agent.run("ask them") + async for event in handler.stream_events(): + if isinstance(event, InputRequiredEvent): + handler.ctx.send_event(HumanResponseEvent(response="yes")) + return str(await handler) + + assert asyncio.run(converse()) == "the human said yes" + + events = read_events(tmp_path) + kinds = types_of(events) + # Both pairs, in this order. Only agent_pause/agent_resume feeds the + # graph's paused time; only human_wait/human_input carries the prompt and + # the pending-human badge. Neither alone is sufficient. + assert kinds.index("human_wait") < kinds.index("agent_pause") + assert kinds.index("agent_pause") < kinds.index("agent_resume") + assert kinds.index("agent_resume") < kinds.index("human_input") + + wait = only(events, "human_wait")[0] + pause = only(events, "agent_pause")[0] + resume = only(events, "agent_resume")[0] + given = only(events, "human_input")[0] + assert wait["input_id"] == pause["pause_id"] == resume["pause_id"] == given["input_id"] + assert wait["prompt"] and "ok?" in wait["prompt"] + assert isinstance(resume["duration_ms"], int) + assert isinstance(given["duration_ms"], int) + assert_rendering_invariants(events) + + +def test_a_paused_tool_does_not_collide_with_its_retry(instrumented, tmp_path): + """LlamaIndex re-runs a paused tool from scratch, reusing its tool_id. + + Two `tool_use`/`tool_result` pairs sharing one `tool_call_id` in a session + would pair wrongly and report a nonsense duration, so the retry is suffixed. + """ + from llama_index.core.workflow import Context, HumanResponseEvent, InputRequiredEvent + + async def ask_human(ctx: Context, question: str) -> str: + """Ask the human a question.""" + answer = await ctx.wait_for_event( + HumanResponseEvent, + waiter_id="ask", + waiter_event=InputRequiredEvent(prefix=question), + ) + return answer.response + + async def converse() -> None: + llm = StubLLM(script=[("ask_human", {"question": "ok?"})], final="done") + handler = calculator(llm, tools=[ask_human]).run("ask them") + async for event in handler.stream_events(): + if isinstance(event, InputRequiredEvent): + handler.ctx.send_event(HumanResponseEvent(response="yes")) + await handler + + asyncio.run(converse()) + + events = read_events(tmp_path) + ids = [event["tool_call_id"] for event in only(events, "tool_use")] + assert ids == ["call_0", "call_0#1"] + assert [e["tool_call_id"] for e in only(events, "tool_result")] == ids + assert only(events, "tool_result")[0]["fw_closed_by"] == "human_wait" + + +# --------------------------------------------------------------------------- +# Structure +# --------------------------------------------------------------------------- + +def test_a_nested_workflow_becomes_a_nested_agent(instrumented, tmp_path): + from llama_index.core.workflow import Event, StartEvent, StopEvent, Workflow, step + + class Mid(Event): + payload: str + + class Inner(Workflow): + @step + async def go(self, ev: StartEvent) -> StopEvent: + return StopEvent(result="inner-done") + + class Outer(Workflow): + @step + async def first(self, ev: StartEvent) -> Mid: + return Mid(payload="hi") + + @step + async def second(self, ev: Mid) -> StopEvent: + return StopEvent(result=str(await Inner(timeout=5).run())) + + assert str(run_workflow(Outer(timeout=5))) == "inner-done" + + events = read_events(tmp_path) + starts = only(events, "agent_start") + assert [start["agent_id"] for start in starts] == ["Outer", "Inner"] + assert "parent_id" not in starts[0] + assert starts[1]["parent_id"] == "Outer" + assert len({event["session_id"] for event in events}) == 1 + assert_rendering_invariants(events) + + +# --------------------------------------------------------------------------- +# AgentWorkflow handoffs +# +# `AgentWorkflow` does NOT run its agents as nested workflows: there is one +# `AgentWorkflow.run` span and the agents are steps inside it. Read off the span +# tree alone a two-agent crew is one flat `agent_id="AgentWorkflow"` and the +# handoff is invisible, so the adapter keys nested agents off the +# `current_agent_name` the runtime puts on every AgentInput/AgentSetup/ +# AgentOutput instead. +# --------------------------------------------------------------------------- + +HANDOFF_TO_ANALYST = ("handoff", {"to_agent": "analyst", "reason": "over to you"}) +HANDOFF_TO_RESEARCHER = ("handoff", {"to_agent": "researcher", "reason": "back to you"}) + + +def crew(researcher_llm, analyst_llm, *, handoff_back=False): + """A real two-agent `AgentWorkflow` — the API LlamaIndex documents.""" + from llama_index.core.agent.workflow import AgentWorkflow, FunctionAgent + + researcher = FunctionAgent( + name="researcher", + description="looks numbers up", + tools=[add], + llm=researcher_llm, + streaming=False, + can_handoff_to=["analyst"], + ) + analyst = FunctionAgent( + name="analyst", + description="does the maths", + tools=[add], + llm=analyst_llm, + streaming=False, + # `None` here would mean "may hand off to anyone", which loops. + can_handoff_to=["researcher"] if handoff_back else [], + ) + return AgentWorkflow(agents=[researcher, analyst], root_agent="researcher") + + +def test_an_agent_workflow_handoff_is_two_nested_agents_not_one_flat_one( + instrumented, tmp_path +): + """The names a customer facets by are `researcher` and `analyst`. + + Flattened, every event in the session carries `agent_id="AgentWorkflow"` and + the two agents are distinguishable only by a payload extra, which + `agent_id`-keyed surfaces cannot group by at all. + """ + workflow = crew( + StubLLM(script=[("add", {"a": 1, "b": 1}), HANDOFF_TO_ANALYST]), + StubLLM(script=[("add", {"a": 2, "b": 3})], final="5"), + ) + assert str(run_workflow(workflow, user_msg="add things")) == "5" + + events = read_events(tmp_path) + starts = only(events, "agent_start") + assert [start["agent_id"] for start in starts] == [ + "AgentWorkflow", + "researcher", + "analyst", + ] + assert "parent_id" not in starts[0] + assert starts[1]["parent_id"] == "AgentWorkflow" + assert starts[2]["parent_id"] == "AgentWorkflow" + assert len({event["session_id"] for event in events}) == 1 + + # Sticky, and this is the subtle half: `ToolCall` carries no + # `current_agent_name`, so a `call_tool` step has to keep whichever agent + # asked for the tool. + assert [(e["agent_id"], e["tool_name"]) for e in only(events, "tool_use")] == [ + ("researcher", "add"), + ("researcher", "handoff"), + ("analyst", "add"), + ] + assert {e["agent_id"] for e in only(events, "model_request")} == { + "researcher", + "analyst", + } + assert_rendering_invariants(events) + + +def test_a_standalone_function_agent_does_not_nest_inside_itself(instrumented, tmp_path): + """The guard on the rule above, and the reason it is `name != agent_id`. + + A standalone `FunctionAgent.run` drives those same `AgentWorkflow` steps + with its OWN name in `current_agent_name`. Without the guard every + single-agent run would open a `calc` nested inside a `calc` — doubling the + agent count on every LlamaIndex session in the product. + """ + llm = StubLLM(script=[("add", {"a": 2, "b": 3})], final="5") + run_agent(calculator(llm), "2+3?") + + events = read_events(tmp_path) + assert [start["agent_id"] for start in only(events, "agent_start")] == ["calc"] + assert types_of(events) == ONE_TOOL_SEQUENCE + assert_rendering_invariants(events) + + +def test_a_handoff_back_opens_the_first_agent_again_as_a_second_turn( + instrumented, tmp_path +): + """A -> B -> A: two turns for `researcher`, each opened and closed on its own. + + The nested agent is keyed per turn rather than per name. Reusing the key of + the `researcher` we already ended would collide in the tracker, and one of + the two `agent_start`s would never be closed. + """ + workflow = crew( + StubLLM(script=[HANDOFF_TO_ANALYST], final="done"), + StubLLM(script=[HANDOFF_TO_RESEARCHER]), + handoff_back=True, + ) + assert str(run_workflow(workflow, user_msg="round trip")) == "done" + + events = read_events(tmp_path) + assert [start["agent_id"] for start in only(events, "agent_start")] == [ + "AgentWorkflow", + "researcher", + "analyst", + "researcher", + ] + # Inner-first, and every open closed: the invariant check below fails on an + # agent left open, which is what a key collision would produce. + assert [end["agent_id"] for end in only(events, "agent_end")] == [ + "researcher", + "analyst", + "researcher", + "AgentWorkflow", + ] + assert_rendering_invariants(events) + + +def test_sub_agents_are_still_attributed_with_the_step_hooks_off( + instrumented_without_steps, tmp_path +): + """`steps=False` drops the hook pairs, not the agents. + + The sub-agent is resolved when the step span OPENS, which is a different + code path from the `hook_triggered` the option suppresses — so it is worth + proving rather than assuming. + """ + workflow = crew( + StubLLM(script=[HANDOFF_TO_ANALYST]), + StubLLM(script=[("add", {"a": 2, "b": 3})], final="5"), + ) + assert str(run_workflow(workflow, user_msg="add things")) == "5" + + events = read_events(tmp_path) + assert only(events, "hook_triggered") == [] + assert only(events, "hook_completed") == [] + assert [start["agent_id"] for start in only(events, "agent_start")] == [ + "AgentWorkflow", + "researcher", + "analyst", + ] + assert [(e["agent_id"], e["tool_name"]) for e in only(events, "tool_use")] == [ + ("researcher", "handoff"), + ("analyst", "add"), + ] + assert_rendering_invariants(events) + + +def test_two_concurrent_runs_do_not_mix(instrumented, tmp_path): + """Two overlapping runs, one process, one dispatcher, no contextvars. + + The whole reason `RunTracker` passes `session_id=` explicitly instead of + reading a contextvar: a start and its end are separate dispatcher calls, + and interleaved runs would otherwise attribute events to whichever run + happened to touch the variable last. + """ + + async def both(): + left = calculator(StubLLM(script=[("add", {"a": 1, "b": 1})], final="2")) + right = calculator(StubLLM(script=[("add", {"a": 3, "b": 4})], final="7")) + return await asyncio.gather(left.run("1+1?"), right.run("3+4?")) + + answers = {str(answer) for answer in asyncio.run(both())} + assert answers == {"2", "7"} + + events = read_events(tmp_path) + sessions = {event["session_id"] for event in events} + assert len(sessions) == 2 + + for session in sessions: + rows = [event for event in events if event["session_id"] == session] + assert types_of(rows) == ONE_TOOL_SEQUENCE + assert_rendering_invariants(rows) + # Each session saw exactly one of the two tool calls, whole. + inputs = [event["input"] for event in rows if event["type"] == "tool_use"] + assert inputs in ([{"a": 1, "b": 1}], [{"a": 3, "b": 4}]) + + +def test_a_retrieval_becomes_a_tool_with_a_summarized_output(instrumented, tmp_path): + from llama_index.core.base.base_retriever import BaseRetriever + from llama_index.core.schema import NodeWithScore, QueryBundle, TextNode + from llama_index.core.workflow import StartEvent, StopEvent, Workflow, step + + class Fake(BaseRetriever): + def _retrieve(self, query_bundle: QueryBundle): + return [ + NodeWithScore(node=TextNode(text="x" * 5000, id_=f"n{i}"), score=1.0) + for i in range(12) + ] + + class Rag(Workflow): + @step + async def go(self, ev: StartEvent) -> StopEvent: + return StopEvent(result=f"{len(Fake().retrieve('what?'))} nodes") + + assert str(run_workflow(Rag(timeout=5))) == "12 nodes" + + events = read_events(tmp_path) + use = only(events, "tool_use")[0] + result = only(events, "tool_result")[0] + assert use["tool_name"] == "Fake" + assert use["input"] == {"query": "what?"} + assert use["fw_kind"] == "retrieval" + # Retrieved documents are the largest strings in the process and the + # payload is not a promoted column: summarize, never ship them whole. + assert result["output"]["num_nodes"] == 12 + assert len(result["output"]["top"]) == 5 + assert len(result["output"]["top"][0]["text"]) <= 200 + assert_rendering_invariants(events) + + +def test_embeddings_are_off_by_default(instrumented, tmp_path): + assert instrumented.state.embeddings is False + + +def test_no_event_carries_an_extra_that_shadows_a_declared_field(instrumented, tmp_path): + """`_schema._build()` merges extras LAST, at the top level. + + So an extra called `tool_name`, `model`, `outcome` or `input_tokens` + silently overwrites the declared field — changing the promoted the events store + column and the server's computed summary while every test still passes. + """ + llm = StubLLM(script=[("add", {"a": 2, "b": 3})], final="5") + run_agent(calculator(llm), "2+3?") + + by_type = {} + for name, obj in vars(_schema).items(): + if name.endswith("Event") and hasattr(obj, "__dataclass_fields__"): + kind = "".join("_" + c.lower() if c.isupper() else c for c in name[:-5]).lstrip("_") + by_type[kind] = {f.name for f in dataclass_fields(obj)} - {"extra_fields"} + + for event in read_events(tmp_path): + declared = by_type[event["type"]] | {"type", "environment"} + extras = set(event) - declared + assert not (extras & _core.FORBIDDEN_EXTRAS), f"{event['type']} shadows {extras}" + for key in extras: + assert key.startswith("fw_") or key in _core.ALLOWED_TOP_LEVEL, ( + f"{event['type']} carries un-namespaced extra {key!r}" + ) + + +# --------------------------------------------------------------------------- +# Teardown +# --------------------------------------------------------------------------- + +def test_uninstrument_detaches_only_our_handlers(tmp_path): + from llama_index.core.instrumentation import get_dispatcher + from llama_index_instrumentation.event_handlers.base import BaseEventHandler + + class Foreign(BaseEventHandler): + def handle(self, event, **kwargs): + return None + + dispatcher = get_dispatcher() + foreign = Foreign() + dispatcher.add_event_handler(foreign) + before_events = list(dispatcher.event_handlers) + before_spans = list(dispatcher.span_handlers) + try: + failproofai_sdk.instrument("llama_index") + assert len(dispatcher.event_handlers) == len(before_events) + 1 + assert len(dispatcher.span_handlers) == len(before_spans) + 1 + failproofai_sdk.uninstrument("llama_index") + # In-place slice assignment: `add_span_handler` does `+= [h]`, so a + # plain `=` rebinds the pydantic field and can drop someone else's. + assert list(dispatcher.event_handlers) == before_events + assert list(dispatcher.span_handlers) == before_spans + assert foreign in dispatcher.event_handlers + finally: + dispatcher.event_handlers[:] = [h for h in dispatcher.event_handlers if h is not foreign] + + +ROOT_SPAN = "Empty.run-11111111-1111-4111-8111-111111111111" +TOOL_SPAN = "FunctionTool.acall-22222222-2222-4222-8222-222222222222" +LLM_SPAN = "StubLLM.astream_chat-44444444-4444-4444-8444-444444444444" + + +def _bound_args(): + """A real `inspect.BoundArguments`, which is what the dispatcher passes.""" + + def target(a=None, b=None): + return None + + return inspect.signature(target).bind() + + +def _empty_workflow(): + from llama_index.core.workflow import StartEvent, StopEvent, Workflow, step + + class Empty(Workflow): + @step + async def go(self, ev: StartEvent) -> StopEvent: + return StopEvent(result="ok") + + return Empty() + + +def _fake_tool(): + class _Metadata: + name = "adder" + + class _Tool(BaseTool): + metadata = _Metadata() + + def __call__(self, *args, **kwargs): + return None + + return _Tool() + + +def _open_a_run_holding_a_tool(state): + """Drive the real span handler to a run with one leaf still open. + + Synthetic span ids, real handler, real base class: this is the state a + process is in when a run dies mid-tool, which no cooperative workflow will + produce on demand. + """ + _, span_cls = adapter_module.handler_classes() + handler = span_cls(state=state) + bound = _bound_args() + handler.span_enter(id_=ROOT_SPAN, bound_args=bound, instance=_empty_workflow(), parent_id=None) + handler.span_enter(id_=TOOL_SPAN, bound_args=bound, instance=_fake_tool(), parent_id=ROOT_SPAN) + assert state._runs[ROOT_SPAN].open_leaves, "the tool leaf should be open" + return handler, bound + + +def test_a_run_that_ends_holding_an_open_tool_closes_it(instrumented, tmp_path): + """Invariant 4, at the run boundary. + + `agent_end` force-closes open *pauses* but not tools or models, so a run + that finishes while a leaf is open leaves the session `ongoing` forever — + and nothing else in the suite notices, because a cooperative workflow + always closes its own leaves. + """ + state = instrumented.state + handler, bound = _open_a_run_holding_a_tool(state) + + handler.span_exit(id_=ROOT_SPAN, bound_args=bound, instance=None, result=None) + + events = read_events(tmp_path) + assert types_of(events) == ["agent_start", "tool_use", "tool_result", "agent_end"] + assert only(events, "tool_result")[0]["fw_closed_by"] == "run_ended" + assert only(events, "agent_end")[0]["outcome"] == "success" + assert_rendering_invariants(events) + + +def test_shutdown_closes_every_leaf_a_dead_run_left_open(instrumented, tmp_path): + """Same invariant at the uninstrument boundary: nothing is left dangling.""" + state = instrumented.state + _open_a_run_holding_a_tool(state) + + failproofai_sdk.uninstrument("llama_index") + + events = read_events(tmp_path) + assert types_of(events) == ["agent_start", "tool_use", "tool_result", "agent_end"] + assert only(events, "tool_result")[0]["fw_closed_by"] == "uninstrument" + assert only(events, "agent_end")[0]["outcome"] == "cancelled" + assert_rendering_invariants(events) + + +def test_the_reaper_closes_a_stale_parked_stream(instrumented, tmp_path): + """A streaming response nobody consumes never gets an LLMChatEndEvent. + + Its span has already exited, so nothing else will ever close it: without + the sweep the model_request stays open and the session reads `ongoing` + forever. + """ + state = instrumented.state + event_cls, span_cls = adapter_module.handler_classes() + spans = span_cls(state=state) + events_handler = event_cls(state=state) + + from llama_index.core.instrumentation.events.llm import LLMChatStartEvent + + bound = _bound_args() + spans.span_enter(id_=ROOT_SPAN, bound_args=bound, instance=_empty_workflow(), parent_id=None) + spans.span_enter(id_=LLM_SPAN, bound_args=bound, instance=StubLLM(), parent_id=ROOT_SPAN) + events_handler.handle( + LLMChatStartEvent(span_id=LLM_SPAN, messages=[], additional_kwargs={}, model_dict={}) + ) + # The span exits the instant the generator is created; nobody consumes it. + spans.span_exit(id_=LLM_SPAN, bound_args=bound, instance=None, result=iter([])) + + assert state._leaf_run.get(LLM_SPAN) == ROOT_SPAN + state.stale_after = 0.0 + assert state.sweep() == 1 + assert state.sweep() == 0 + + response = only(read_events(tmp_path), "model_response")[0] + assert response["fw_closed_by"] == "stale" + assert isinstance(response["duration_ms"], int) + + +# --------------------------------------------------------------------------- +# Cancellation +# +# `handler.cancel_run()` does NOT drop the run span. The runtime catches its own +# `WorkflowCancelledByUser` and exits the span cleanly, with `result=None` and +# no error — "so it shows as OK rather than ERROR in traces". Read off the span +# alone, a user pressing stop is indistinguishable from a completed run, which +# is why the adapter listens for `SpanCancelledEvent`. +# --------------------------------------------------------------------------- + +def _cancel_a_run_mid_step() -> None: + """Cancel a real run while a step is in flight, the way a stop button does.""" + from llama_index.core.workflow import StartEvent, StopEvent, Workflow, step + + started = asyncio.Event() + + class Slow(Workflow): + @step + async def go(self, ev: StartEvent) -> StopEvent: + started.set() + await asyncio.sleep(30) + return StopEvent(result="never") + + async def _main() -> None: + handler = Slow(timeout=30).run() + await asyncio.wait_for(started.wait(), timeout=5) + await handler.cancel_run() + with pytest.raises(BaseException): + await handler + + asyncio.run(_main()) + + +def test_a_cancelled_run_is_not_reported_as_a_success(instrumented, tmp_path): + """Reporting a cancellation as success inflates the completion rate. + + `cancelled` is deliberately not `failed` either: the server counts only + `error|failed|timeout|rejected` as a failure, and a stop button is neither. + """ + _cancel_a_run_mid_step() + + events = read_events(tmp_path) + assert only(events, "agent_end")[0]["outcome"] == "cancelled" + # A cancellation is not an error, so nothing may report one. + assert only(events, "error") == [] + assert_rendering_invariants(events) + + +def test_a_step_cancelled_mid_flight_is_not_reported_as_a_success( + instrumented, tmp_path +): + """Same signal one level down: the step exits with `result=None`, no error.""" + _cancel_a_run_mid_step() + + completed = only(read_events(tmp_path), "hook_completed") + assert completed, "the in-flight step still has to close" + assert [hook["outcome"] for hook in completed] == ["cancelled"] + + +# --------------------------------------------------------------------------- +# Structural anti-drift — the one that catches a silently dead adapter +# --------------------------------------------------------------------------- + +def _our_overrides(cls, base) -> set[str]: + return { + name + for name in vars(cls) + if not name.startswith("__") + and inspect.isroutine(getattr(cls, name, None)) + and hasattr(base, name) + } + + +def _named_params(func) -> set[str]: + return { + name + for name, parameter in inspect.signature(func).parameters.items() + if name not in {"self", "cls"} + and parameter.kind + not in (inspect.Parameter.VAR_KEYWORD, inspect.Parameter.VAR_POSITIONAL) + } + + +def test_our_overrides_still_exist_on_the_framework_base_classes(): + """If upstream renames a callback, our override becomes DEAD CODE. + + It is never called, nothing raises, and every other test in this file still + passes because they exercise the adapter through our own objects. This is + the only test that notices. + """ + from llama_index_instrumentation.event_handlers.base import BaseEventHandler + from llama_index_instrumentation.span_handlers.base import BaseSpanHandler + + event_cls, span_cls = adapter_module.handler_classes() + + for cls, base, expected in ( + (event_cls, BaseEventHandler, {"handle"}), + (span_cls, BaseSpanHandler, {"new_span", "prepare_to_exit_span", "prepare_to_drop_span"}), + ): + overrides = _our_overrides(cls, base) + assert expected <= overrides, f"{cls.__name__} no longer overrides {expected - overrides}" + for name in overrides: + ours = getattr(cls, name) + theirs = getattr(base, name, None) + assert inspect.isroutine(theirs), f"{base.__name__}.{name} is gone" + missing = _named_params(ours) - _named_params(theirs) + assert not missing, ( + f"{cls.__name__}.{name} declares {sorted(missing)}, which " + f"{base.__name__}.{name} no longer accepts by name" + ) + + +def test_the_span_enter_to_new_span_kwarg_rename_still_holds(): + """`span_enter(parent_id=...)` calls `new_span(parent_span_id=...)`. + + Declaring the wrong one is not an error — it lands in `**kwargs`, every + parent comes through as None, and the whole trace renders flat. + """ + from llama_index_instrumentation.span_handlers.base import BaseSpanHandler + + assert "parent_id" in _named_params(BaseSpanHandler.span_enter) + assert "parent_span_id" in _named_params(BaseSpanHandler.new_span) + assert "parent_span_id" not in _named_params(BaseSpanHandler.span_enter) + + _, span_cls = adapter_module.handler_classes() + assert "parent_span_id" in _named_params(span_cls.new_span) + + +def test_every_dispatcher_event_class_we_dispatch_on_still_exists(): + """We dispatch on the event's class NAME, so these names are the API. + + A rename upstream leaves the table below looking perfectly healthy while + the adapter records nothing at all. + """ + import importlib + + modules = [ + importlib.import_module(f"llama_index.core.instrumentation.events.{name}") + for name in ("llm", "chat_engine", "retrieval", "embedding", "exception") + ] + for name in adapter_module._HANDLED_EVENTS: + assert any(hasattr(module, name) for module in modules), ( + f"{name} no longer exists in llama_index.core.instrumentation.events" + ) + + +def test_the_cancel_event_we_dispatch_on_still_exists_where_we_expect_it(): + """`CANCEL_EVENTS` has no other guard in this file, by construction. + + Every other name we dispatch on lives under + `llama_index.core.instrumentation.events.*`, which the test above walks. + This one is dispatched by the workflows RUNTIME, so that test cannot see it + — and if it is renamed or moved, nothing raises: cancelled runs quietly go + back to being reported as successes. + """ + import importlib + + from llama_index.core.instrumentation.events.base import BaseEvent + + module = importlib.import_module("workflows.runtime.types.step_function") + for name in adapter_module.CANCEL_EVENTS: + cls = getattr(module, name, None) + assert cls is not None, f"{name} is gone from {module.__name__}" + assert issubclass(cls, BaseEvent), f"{name} is no longer a dispatcher event" + # We match on the class name and pair the mark with the span_exit behind + # it using `span_id`, which the dispatcher stamps from the active span. + assert cls.class_name() == name + assert "span_id" in cls.model_fields + + # And the exception that path exists to serve, which `cancel_run()` raises. + from workflows.errors import WorkflowCancelledByUser + + assert issubclass(WorkflowCancelledByUser, BaseException) + + +@pytest.mark.parametrize( + ("module", "event", "attributes"), + [ + ("llm", "LLMChatStartEvent", ("messages", "model_dict")), + ("llm", "LLMChatEndEvent", ("messages", "response")), + ("retrieval", "RetrievalStartEvent", ("str_or_query_bundle",)), + ("retrieval", "RetrievalEndEvent", ("nodes",)), + ("exception", "ExceptionEvent", ("exception",)), + ], +) +def test_the_event_attributes_we_read_still_exist(module, event, attributes): + import importlib + + cls = getattr(importlib.import_module(f"llama_index.core.instrumentation.events.{module}"), event) + for attribute in attributes: + assert attribute in cls.model_fields, f"{event}.{attribute} is gone" + + +def test_the_waiting_for_event_signal_still_looks_like_we_think_it_does(): + """HITL hinges on a name match: `WaitingForEvent` is a pause, not an error. + + It is not exported from `workflows.errors` and has moved before, so we + match on the class name. If it is renamed, every human-in-the-loop pause + becomes a red error event and a failed run. + """ + from workflows.runtime.types.results import AddWaiter, WaitingForEvent + + assert WaitingForEvent.__name__ == "WaitingForEvent" + assert issubclass(WaitingForEvent, Exception) + assert "waiter_id" in AddWaiter.model_fields + assert "waiter_event" in AddWaiter.model_fields + assert adapter_module._is_waiting(WaitingForEvent.__new__(WaitingForEvent)) is True + assert adapter_module._is_waiting(RuntimeError("nope")) is False + + +def test_the_dispatcher_surface_we_register_on_still_exists(): + from llama_index.core.instrumentation import get_dispatcher + + dispatcher = get_dispatcher() + assert dispatcher.name == "root", "get_dispatcher() must return the ROOT dispatcher" + assert callable(dispatcher.add_event_handler) + assert callable(dispatcher.add_span_handler) + # Child dispatchers propagate upward; that is why one registration is enough. + assert get_dispatcher("llama_index.core.something").propagate is True + + +# --------------------------------------------------------------------------- +# Registry wiring +# --------------------------------------------------------------------------- + +def test_the_registry_reaches_this_adapter_by_all_its_spellings(): + from failproofai_sdk.integrations import _canonical + + for spelling in ("llama_index", "llamaindex", "llama-index", "LlamaIndex"): + assert _canonical(spelling) == "llama_index" + + +def test_instrumenting_twice_is_a_no_op(tmp_path): + from failproofai_sdk.integrations import active + + try: + assert failproofai_sdk.instrument("llama_index") == ("llama_index",) + assert failproofai_sdk.instrument("llama_index") == () + assert "llama_index" in active() + finally: + failproofai_sdk.uninstrument("llama_index") + + +def test_uninstrumenting_something_that_was_never_installed_is_a_no_op(): + assert failproofai_sdk.uninstrument("llama_index") == () + + +def test_the_adapter_joins_a_hand_written_agent_scope(instrumented, tmp_path): + """The interop story: adapter events land in the ambient session. + + `RunTracker.identity()` falls back to `failproofai_sdk.current()`, so mixing the + manual API with an adapter produces one tree rather than two. + """ + llm = StubLLM(script=[], final="5") + with failproofai_sdk.agent("planner", goal="do maths") as identity: + run_agent(calculator(llm), "2+3?") + session_id = identity.session_id + + events = read_events(tmp_path) + assert {event["session_id"] for event in events} == {session_id} + starts = only(events, "agent_start") + assert [start["agent_id"] for start in starts] == ["planner", "calc"] + assert starts[1]["parent_id"] == "planner" diff --git a/sdk/python/tests/integrations/test_pydantic_ai.py b/sdk/python/tests/integrations/test_pydantic_ai.py new file mode 100644 index 000000000..43e69b22a --- /dev/null +++ b/sdk/python/tests/integrations/test_pydantic_ai.py @@ -0,0 +1,1068 @@ +"""The Pydantic AI adapter, against the real framework and a fake model. + +Everything here runs a genuine `pydantic_ai.Agent` — `TestModel` and +`FunctionModel` ship in the package for exactly this — and then asserts on the +**JSONL the writer actually wrote**, not on mock call args. A mock-based test +of an adapter proves that the adapter calls the functions the test says it +calls, which is the one thing that was never in doubt. + +The single highest-value test in this file is `TestAntiDrift`. Every other test +here would still pass if Pydantic AI renamed `wrap_tool_execute` tomorrow: our +override would simply never be called, the framework would run fine, and we +would silently record nothing. Only reflection over the real base class catches +that. +""" + +import asyncio +import dataclasses +import inspect +import json +import os +import re +import shutil +import uuid + +import pytest + +import failproofai_sdk +from failproofai_sdk import _runtime, _schema +from failproofai_sdk.integrations import _compat, _core + +pytestmark = pytest.mark.framework + +_REQUIRE_FRAMEWORKS = os.environ.get("AGENTEYE_TESTS_REQUIRE_FRAMEWORKS", "").strip().lower() in { + "1", + "true", + "yes", + "on", +} + +try: + from pydantic_ai import Agent, RunContext + from pydantic_ai.capabilities import AbstractCapability + from pydantic_ai.messages import ModelResponse, TextPart, ToolCallPart + from pydantic_ai.models import ModelRequestContext + from pydantic_ai.models.function import FunctionModel + from pydantic_ai.models.test import TestModel + from pydantic_ai.usage import RequestUsage, RunUsage +except ImportError: # pragma: no cover - exercised only on a bare environment + # `pytest.importorskip` is fail-open: misspell the module and every test in + # the file skips while CI stays green having tested nothing. The framework + # CI leg sets AGENTEYE_TESTS_REQUIRE_FRAMEWORKS=1 to turn that into a hard + # failure. + if _REQUIRE_FRAMEWORKS: + raise + pytest.skip("pydantic-ai is not installed", allow_module_level=True) + +from failproofai_sdk.integrations import pydantic_ai as adapter # noqa: E402 +from failproofai_sdk.integrations.pydantic_ai import FailproofAI # noqa: E402 + + +# --------------------------------------------------------------------------- +# Fixtures and helpers +# --------------------------------------------------------------------------- + +@pytest.fixture() +def emitted(tmp_path): + """Read back the real JSONL the writer produced during this test. + + The flush interval goes to an hour because event filenames only have + millisecond resolution: two flushes inside the same millisecond write to the + same path and the second clobbers the first. So the background thread is + parked and every flush in this file is explicit. + """ + _runtime.writer.set_flush_interval(3600) + # Drain anything a previous test left queued, then start from an empty + # directory: the autouse fixture has already pointed base_dir at tmp_path. + failproofai_sdk._writer.flush_now() + events_dir = tmp_path / "events" + if events_dir.exists(): + shutil.rmtree(events_dir) + + def read(): + failproofai_sdk._writer.flush_now() + rows = [] + for path in sorted(events_dir.glob("*.jsonl")): + rows.extend( + json.loads(line) + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ) + return rows + + return read + + +@pytest.fixture() +def instrumented(): + _core.set_strict(False) + _compat.set_strict_integrations(False) + _core.reset_failures() + assert failproofai_sdk.instrument("pydantic_ai") == ("pydantic_ai",) + try: + yield + finally: + failproofai_sdk.uninstrument("pydantic_ai") + _core.set_strict(None) + _compat.set_strict_integrations(None) + + +def types_of(rows): + return [row["type"] for row in rows] + + +def of_type(rows, kind): + return [row for row in rows if row["type"] == kind] + + +def one_shot_tool_model(tool_name, args, *, tool_call_id="call-1", text="done"): + """A model that calls one tool, then answers. Deterministic, no network.""" + state = {"n": 0} + + def respond(messages, info): + state["n"] += 1 + if state["n"] == 1: + return ModelResponse(parts=[ToolCallPart(tool_name, args, tool_call_id=tool_call_id)]) + return ModelResponse(parts=[TextPart(text)]) + + return FunctionModel(respond) + + +def weather_agent_with_tool(**kwargs): + agent = Agent( + one_shot_tool_model("get_weather", {"city": "london"}), + name="weather_agent", + **kwargs, + ) + + @agent.tool_plain + def get_weather(city: str) -> str: + return f"sunny in {city}" + + return agent + + +# --------------------------------------------------------------------------- +# The shape of a representative run +# --------------------------------------------------------------------------- + +def test_the_event_type_sequence_for_a_tool_using_run(instrumented, emitted): + result = weather_agent_with_tool().run_sync("weather in london?") + + assert result.output == "done" + assert types_of(emitted()) == [ + "agent_start", + "model_request", + "model_response", + "tool_use", + "tool_result", + "model_request", + "model_response", + "agent_end", + ] + + +def test_the_root_agent_start_is_the_sessions_first_event(instrumented, emitted): + weather_agent_with_tool().run_sync("go") + rows = emitted() + + # `agent_sessions.agent_id = any(...)` over an ORDER BY (session_id, ts) + # table returns the FIRST-by-time agent_id, so anything emitted ahead of the + # root agent_start becomes the name of the whole session in the list view. + assert rows[0]["type"] == "agent_start" + assert rows[0]["agent_id"] == "weather_agent" + assert len({row["session_id"] for row in rows}) == 1 + + +def test_an_async_run_produces_the_same_sequence(instrumented, emitted): + agent = weather_agent_with_tool() + + async def main(): + return await agent.run("go") + + result = asyncio.run(main()) + + assert result.output == "done" + assert types_of(emitted()) == [ + "agent_start", + "model_request", + "model_response", + "tool_use", + "tool_result", + "model_request", + "model_response", + "agent_end", + ] + + +# --------------------------------------------------------------------------- +# Correlation and duration +# --------------------------------------------------------------------------- + +def test_model_events_pair_on_request_id_and_carry_an_int_duration(instrumented, emitted): + weather_agent_with_tool().run_sync("go") + rows = emitted() + + requests = of_type(rows, "model_request") + responses = of_type(rows, "model_response") + assert len(requests) == len(responses) == 2 + + request_ids = [row["request_id"] for row in requests] + assert request_ids == [row["request_id"] for row in responses] + assert len(set(request_ids)) == 2, "request_id must be unique per model call" + + for response in responses: + # `durationOf` prefers the closing event's duration_ms over end-start, + # which is what keeps model durations honest even when the dashboard's + # FIFO pairing brackets the wrong pair. A float silently NULLs the + # promoted u32 column, so the type is the assertion. + assert type(response["duration_ms"]) is int + + +def test_tool_events_pair_on_tool_call_id_and_carry_a_duration(instrumented, emitted): + weather_agent_with_tool().run_sync("go") + rows = emitted() + + (use,) = of_type(rows, "tool_use") + (result,) = of_type(rows, "tool_result") + assert use["tool_call_id"] == result["tool_call_id"] == "call-1" + assert use["tool_name"] == result["tool_name"] == "get_weather" + # Auto-computed by the SDK from the shared tool_call_id: if the adapter ever + # passed two different ids the pairing would break and this would be absent. + assert type(result["duration_ms"]) is int + assert result["output"] == "sunny in london" + + +def test_the_tool_input_is_captured(instrumented, emitted): + weather_agent_with_tool().run_sync("go") + (use,) = of_type(emitted(), "tool_use") + assert use["input"] == {"city": "london"} + + +def test_capture_content_off_drops_payloads_but_keeps_the_structure(emitted): + _core.set_strict(False) + failproofai_sdk.instrument("pydantic_ai", capture_content=False) + try: + weather_agent_with_tool().run_sync("go") + finally: + failproofai_sdk.uninstrument("pydantic_ai") + _core.set_strict(None) + rows = emitted() + + assert types_of(rows) == [ + "agent_start", + "model_request", + "model_response", + "tool_use", + "tool_result", + "model_request", + "model_response", + "agent_end", + ] + assert "goal" not in rows[0] + assert "input" not in of_type(rows, "tool_use")[0] + assert "output" not in of_type(rows, "tool_result")[0] + assert "content" not in of_type(rows, "model_response")[0] + + +def test_usage_is_reported_both_as_ints_and_as_a_normalized_dict(instrumented, emitted): + weather_agent_with_tool().run_sync("go") + rows = emitted() + + response = of_type(rows, "model_response")[0] + assert isinstance(response["input_tokens"], int) + assert isinstance(response["output_tokens"], int) + # Both event_summary.rs and sessionSummary.ts fall back to `usage` when the + # promoted ints are missing, so it ships too. + assert response["usage"]["input_tokens"] == response["input_tokens"] + + (end,) = of_type(rows, "agent_end") + assert end["usage"]["requests"] == 2 + assert end["usage"]["tool_calls"] == 1 + + +# --------------------------------------------------------------------------- +# Identity, labelling, payload hygiene +# --------------------------------------------------------------------------- + +def test_agent_ids_are_readable_names_and_never_uuids(instrumented, emitted): + weather_agent_with_tool().run_sync("go") + rows = emitted() + + ids = {row["agent_id"] for row in rows} + assert ids == {"weather_agent"} + for value in ids: + with pytest.raises(ValueError): + # agent_id is a LowCardinality(String) and the primary facet on every + # dashboard surface; a UUID in it poisons that facet permanently. + uuid.UUID(value) + + +def test_an_unnamed_agent_is_labelled_from_its_variable_not_its_run_id(instrumented, emitted): + forecast_agent = Agent(TestModel(call_tools=[])) + forecast_agent.run_sync("go") + + assert {row["agent_id"] for row in emitted()} == {"forecast_agent"} + + +def test_every_event_carries_the_framework_triple(instrumented, emitted): + weather_agent_with_tool().run_sync("go") + rows = emitted() + + assert rows, "no events were emitted at all" + for row in rows: + assert row["framework"] == "pydantic_ai", row["type"] + assert row["framework_version"], row["type"] + assert row["integration_version"], row["type"] + + +TYPE_OF_DATACLASS = { + re.sub(r"(?<!^)(?=[A-Z])", "_", name[: -len("Event")]).lower(): obj + for name, obj in vars(_schema).items() + if isinstance(obj, type) and name.endswith("Event") +} + + +def test_the_type_table_covers_the_whole_schema(): + # Guards the test below: a bad CamelCase split would make it vacuous. + assert len(TYPE_OF_DATACLASS) == 15 + assert {"tool_use", "model_response", "agent_end", "hook_triggered", "error"} <= set( + TYPE_OF_DATACLASS + ) + + +def test_no_event_carries_a_field_that_shadows_a_declared_one(instrumented, emitted): + """`_schema._build()` ends with `result.update(extra)`. + + So an extra field named `tool_name`, `model`, `outcome` or `input_tokens` + silently overwrites the declared one — changing the promoted the events store + column and the server's computed summary — while every other test still + passes. Every key we emit must therefore be a declared field of that event's + own dataclass, a deliberate top-level name, or `fw_*`. + """ + weather_agent_with_tool().run_sync("go") + rows = emitted() + assert rows + + reserved = {"timestamp", "session_id", "agent_id", "type", "environment"} + for row in rows: + declared = { + field.name for field in dataclasses.fields(TYPE_OF_DATACLASS[row["type"]]) + } + for key in row: + assert ( + key in declared + or key in reserved + or key in _core.ALLOWED_TOP_LEVEL + or key.startswith("fw_") + ), f"{row['type']}.{key} is neither declared, allow-listed, nor fw_-namespaced" + + +def test_framework_detail_rides_in_the_fw_namespace(instrumented, emitted): + weather_agent_with_tool().run_sync("go") + rows = emitted() + + start = rows[0] + assert start["fw_conversation_id"] + uuid.UUID(start["fw_run_id"]) # the real run id is kept, just not as agent_id + assert of_type(rows, "model_request")[0]["fw_run_step"] == 1 + + +# --------------------------------------------------------------------------- +# Failure paths +# --------------------------------------------------------------------------- + +def test_a_failing_tool_is_reported_on_its_own_span_and_the_run_once(instrumented, emitted): + agent = Agent(one_shot_tool_model("explode", {"x": "1"}), name="boom_agent", retries=0) + + @agent.tool_plain + def explode(x: str) -> str: + raise RuntimeError("kaboom") + + with pytest.raises(RuntimeError): + agent.run_sync("go") + rows = emitted() + + assert types_of(rows) == [ + "agent_start", + "model_request", + "model_response", + "tool_use", + "tool_result", + "error", + "agent_end", + ] + (result,) = of_type(rows, "tool_result") + assert result["error"] == "RuntimeError: kaboom" + + # Exactly one standalone `error` event: the failure escaped the run, so no + # leaf span owns it. Two would double-count in sessionSummary.errorCount. + errors = of_type(rows, "error") + assert len(errors) == 1 + assert errors[0]["error_type"] == "RuntimeError" + assert errors[0]["message"] == "kaboom" + # The traceback is trimmed from the FRONT, so the exception line — the only + # one anybody reads — survives a stack deeper than the 8KB field limit. + assert errors[0]["traceback"].rstrip().endswith("RuntimeError: kaboom") + + # "failed", never "failure" — the server counts only + # error|failed|timeout|rejected as a failure. + (end,) = of_type(rows, "agent_end") + assert end["outcome"] == "failed" + # ...and strictly after the error event: the dashboard closes the agent span + # at agent_end and anything after it is attributed to nothing. + assert types_of(rows).index("error") < types_of(rows).index("agent_end") + + +def test_a_failing_model_request_closes_its_own_span_with_the_error(instrumented, emitted): + def explode(messages, info): + raise RuntimeError("provider is down") + + agent = Agent(FunctionModel(explode), name="model_down_agent") + with pytest.raises(RuntimeError): + agent.run_sync("go") + rows = emitted() + + assert types_of(rows) == [ + "agent_start", + "model_request", + "model_response", + "error", + "agent_end", + ] + (response,) = of_type(rows, "model_response") + # Invariant: a model_request always gets a model_response, even when the call + # blew up — otherwise the leaf never closes and the session reads `ongoing` + # forever. + assert response["error"] == "RuntimeError: provider is down" + assert response["request_id"] == of_type(rows, "model_request")[0]["request_id"] + assert type(response["duration_ms"]) is int + assert len(of_type(rows, "error")) == 1 + + +def test_a_tool_retry_does_not_fail_the_run(instrumented, emitted): + from pydantic_ai import ModelRetry + + calls = {"model": 0, "tool": 0} + + def respond(messages, info): + calls["model"] += 1 + if calls["model"] <= 2: + return ModelResponse( + parts=[ToolCallPart("flaky", {"x": "1"}, tool_call_id=f"call-{calls['model']}")] + ) + return ModelResponse(parts=[TextPart("recovered")]) + + agent = Agent(FunctionModel(respond), name="retry_agent") + + @agent.tool_plain + def flaky(x: str) -> str: + calls["tool"] += 1 + if calls["tool"] == 1: + raise ModelRetry("try again") + return "ok" + + assert agent.run_sync("go").output == "recovered" + rows = emitted() + + # The retry is visible on the tool span that owned it... + results = of_type(rows, "tool_result") + assert results[0]["error"].startswith("ToolRetryError") + assert "error" not in results[1], "the recovered attempt was marked failed" + # ...and nowhere else: the run recovered, so it is not a run-level error. + assert of_type(rows, "error") == [] + assert of_type(rows, "agent_end")[0]["outcome"] == "success" + + +def test_a_cancelled_run_is_not_an_error(instrumented, emitted): + async def main(): + started = asyncio.Event() + agent = Agent( + FunctionModel( + lambda messages, info: ModelResponse( + parts=[ToolCallPart("hang", {}, tool_call_id="call-hang")] + ) + ), + name="cancelled_agent", + ) + + @agent.tool_plain + async def hang() -> str: # pragma: no cover - cancelled before it returns + started.set() + await asyncio.sleep(30) + return "never" + + task = asyncio.create_task(agent.run("go")) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + asyncio.run(main()) + rows = emitted() + + # A cancellation is not a failure: it must not pollute the Errors surface. + assert of_type(rows, "error") == [] + assert of_type(rows, "agent_end")[0]["outcome"] == "cancelled" + # Every open leaf still closes — a run that dies with an open tool_use leaves + # the session `ongoing` forever. The tool runs in its own task and its own + # hook returns *after* `wrap_run` does, so the run closes it on the way out; + # see `test_a_cancelled_leaf_closes_before_the_agent_it_belongs_to`. + assert len(of_type(rows, "tool_use")) == len(of_type(rows, "tool_result")) == 1 + assert of_type(rows, "tool_result")[0].get("error") is None + + +# --------------------------------------------------------------------------- +# A leaf that outlives its own run +# +# Shape A promises the start and the end of a span sit in one frame, and for the +# run itself that holds. It does not hold *between* frames: the graph awaits a +# `gather` of tool tasks, so a cancellation unwinds the run body the moment that +# future is cancelled while each tool task's own `CancelledError` is delivered a +# loop iteration later. Measured against pydantic-ai 2.32 before the fix, a +# `wait_for` timeout put `tool_result` 1ms *after* `agent_end`, and a timeout +# inside the provider call did the same to `model_response` — which is the one +# thing every other emit in the adapter is careful never to do, because the +# dashboard closes the agent span at `agent_end`. +# --------------------------------------------------------------------------- + +def test_a_cancelled_leaf_closes_before_the_agent_it_belongs_to(instrumented, emitted): + async def main(): + started = asyncio.Event() + agent = Agent( + FunctionModel( + lambda messages, info: ModelResponse( + parts=[ToolCallPart("hang", {}, tool_call_id="call-hang")] + ) + ), + name="late_leaf_agent", + ) + + @agent.tool_plain + async def hang() -> str: # pragma: no cover - cancelled before it returns + started.set() + await asyncio.sleep(30) + return "never" + + task = asyncio.create_task(agent.run("go")) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + # Give the tool task's own unwind a chance to run: the whole point is + # that it lands after the run, and a duplicate would appear here. + await asyncio.sleep(0.05) + + asyncio.run(main()) + rows = emitted() + kinds = types_of(rows) + + assert kinds.index("tool_result") < kinds.index("agent_end") + # Exactly one, not two: the tool's own hook still runs afterwards and must + # not emit a second close for a span that is already closed. + assert kinds.count("tool_result") == 1 + (result,) = of_type(rows, "tool_result") + assert result["tool_call_id"] == of_type(rows, "tool_use")[0]["tool_call_id"] + # Marked, so a leaf that never reported its own outcome is distinguishable + # from one that completed. + assert result["fw_incomplete"] is True + # A cancellation is still not a failure. + assert result.get("error") is None + assert of_type(rows, "agent_end")[0]["outcome"] == "cancelled" + + +def test_a_cancelled_model_request_closes_before_the_agent_it_belongs_to(instrumented, emitted): + async def main(): + started = asyncio.Event() + + async def never(messages, info): # pragma: no cover - cancelled mid-call + started.set() + await asyncio.sleep(30) + return ModelResponse(parts=[TextPart("never")]) + + agent = Agent(FunctionModel(never), name="late_model_agent") + task = asyncio.create_task(agent.run("go")) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + await asyncio.sleep(0.05) + + asyncio.run(main()) + rows = emitted() + kinds = types_of(rows) + + assert kinds.index("model_response") < kinds.index("agent_end") + assert kinds.count("model_response") == 1 + (response,) = of_type(rows, "model_response") + assert response["request_id"] == of_type(rows, "model_request")[0]["request_id"] + assert response["fw_incomplete"] is True + # `duration_ms` is still an int on the synthesized close: the server's JSON + # parser drops floats, so a float silently NULLs the column. + assert type(response["duration_ms"]) is int + assert response.get("error") is None + + +def test_a_streamed_model_response_says_it_was_streamed(instrumented, emitted): + """`duration_ms` on a streamed response is the whole `async with` block. + + Pydantic AI hands the completed `ModelResponse` back only once the caller + leaves `agent.run_stream(...)`, so the consumer's own time is inside the + number — measured against a live gateway, 1.5s of `asyncio.sleep` in the + consumer moved a 3294ms response to 4677ms. No hook closes the span any + earlier, so the flag rides on the response as well as the request: a latency + percentile can exclude these rows instead of averaging UI time into a p95. + """ + + # TestModel, not FunctionModel: only the former can serve a streamed request + # without a hand-written `stream_function`. + agent = Agent(TestModel(), name="streamed_agent") + + async def main(): + async with agent.run_stream("go") as result: + async for _ in result.stream_text(delta=True): + pass + await result.get_output() + + asyncio.run(main()) + rows = emitted() + + responses = of_type(rows, "model_response") + assert responses, types_of(rows) + assert [r.get("fw_streaming") for r in responses] == [True] * len(responses) + assert all(type(r["duration_ms"]) is int for r in responses) + + # ...and a non-streamed run still says so, or the flag means nothing. + agent.run_sync("go") + later = of_type(emitted(), "model_response")[len(responses):] + assert later + assert [r.get("fw_streaming") for r in later] == [False] * len(later) + + +def test_a_completed_leaf_is_never_marked_incomplete(instrumented, emitted): + """The teardown path must not leak into the ordinary one.""" + weather_agent_with_tool().run_sync("weather in london?") + rows = emitted() + + assert [r for r in rows if r.get("fw_incomplete")] == [] + assert types_of(rows).index("tool_result") < types_of(rows).index("agent_end") + + +def test_uninstrumenting_mid_run_closes_the_run_exactly_once(instrumented, emitted): + """`uninstrument()` and the run's own end are both allowed to go first. + + Before the fix they both went: teardown emitted `agent_end` (`cancelled`) + and the run then emitted a second `agent_end` (`success`) against a span the + dashboard had already closed, with the tool's `tool_result` stranded between + them. + """ + + async def main(): + started = asyncio.Event() + release = asyncio.Event() + agent = Agent( + one_shot_tool_model("wait", {}, tool_call_id="call-wait"), + name="torn_down_agent", + ) + + @agent.tool_plain + async def wait() -> str: + started.set() + await release.wait() + return "finished" + + task = asyncio.create_task(agent.run("go")) + await started.wait() + failproofai_sdk.uninstrument("pydantic_ai") + release.set() + await task + + asyncio.run(main()) + rows = emitted() + kinds = types_of(rows) + + assert kinds.count("agent_start") == 1 + assert kinds.count("agent_end") == 1, kinds + assert of_type(rows, "agent_end")[0]["outcome"] == "cancelled" + # ...and the leaf still closed, inside the span it belongs to. + assert kinds.count("tool_result") == 1 + assert kinds.index("tool_result") < kinds.index("agent_end") + + +# --------------------------------------------------------------------------- +# What `tool_result.output` actually says +# --------------------------------------------------------------------------- + +def _tool_returning(value, *, name="produce"): + agent = Agent(one_shot_tool_model(name, {}), name="output_shape_agent") + agent.tool_plain(lambda: value, name=name) + return agent + + +def test_a_tool_returning_a_pydantic_model_is_recorded_as_its_fields(instrumented, emitted): + from pydantic import BaseModel + + class Weather(BaseModel): + city: str + celsius: int + + _tool_returning(Weather(city="Faro", celsius=21)).run_sync("go") + + (result,) = of_type(emitted(), "tool_result") + # Not "Weather(city='Faro', celsius=21)": `truncate` reprs an object with no + # JSON shape, and this one has one. + assert result["output"] == {"city": "Faro", "celsius": 21} + + +def test_a_tool_returning_a_dataclass_is_recorded_as_its_fields(instrumented, emitted): + @dataclasses.dataclass + class Point: + x: int + y: int + + _tool_returning(Point(1, 2)).run_sync("go") + + (result,) = of_type(emitted(), "tool_result") + assert result["output"] == {"x": 1, "y": 2} + + +def test_a_tool_returning_ToolReturn_is_recorded_as_its_return_value(instrumented, emitted): + """`ToolReturn` is an envelope, and the envelope is not the answer. + + `return_value` is what goes back to the model; `metadata` is documented as + never being shown to it at all. Recording the repr of the whole thing buries + the one and publishes the other. + """ + from pydantic_ai.messages import ToolReturn + + _tool_returning( + ToolReturn( + return_value={"answer": 42}, + content="the model sees this", + metadata={"secret": "not for the model"}, + ) + ).run_sync("go") + + (result,) = of_type(emitted(), "tool_result") + assert result["output"] == {"answer": 42} + + +def test_an_ordinary_tool_return_value_is_untouched(instrumented, emitted): + """The unwrapping is narrow: only shapes that have a JSON form.""" + _tool_returning("sunny in london").run_sync("go") + + (result,) = of_type(emitted(), "tool_result") + assert result["output"] == "sunny in london" + + +class _NoJsonShape: + def __repr__(self) -> str: + return "<opaque handle>" + + +def test_an_object_with_no_json_shape_is_handed_through_untouched(): + """The unwrapping must not become a second, worse serializer. + + Anything that is not a Pydantic model, a dataclass or a `ToolReturn` comes + back byte-identical, so `_core.truncate` keeps deciding what happens to it — + including the `repr` fallback it documents for an object with no JSON shape. + (Unit-level: pydantic-ai itself refuses to send such a value to a model, so + there is no end-to-end run that reaches this line.) + """ + opaque = _NoJsonShape() + assert adapter._tool_output(opaque) is opaque + assert _core.truncate(adapter._tool_output(opaque)) == "<opaque handle>" + + # A class object is not an instance, and `dataclasses.is_dataclass` is True + # for both. + @dataclasses.dataclass + class Shape: + x: int + + assert adapter._tool_output(Shape) is Shape + + +# --------------------------------------------------------------------------- +# Interop with the hand-written API +# --------------------------------------------------------------------------- + +def test_an_enclosing_agenteye_scope_owns_the_session(instrumented, emitted): + with failproofai_sdk.agent("planner", goal="what is the weather?"): + weather_agent_with_tool().run_sync("go") + rows = emitted() + + assert len({row["session_id"] for row in rows}) == 1, "the adapter split the session in two" + assert types_of(rows)[0] == "agent_start" + assert rows[0]["agent_id"] == "planner" + (nested,) = [ + row for row in rows if row["type"] == "agent_start" and row["agent_id"] == "weather_agent" + ] + assert nested["parent_id"] == "planner" + + +def test_a_nested_agent_run_nests(instrumented, emitted): + researcher = Agent(TestModel(call_tools=[]), name="researcher") + supervisor = Agent(one_shot_tool_model("delegate", {"q": "x"}), name="supervisor") + + @supervisor.tool_plain + async def delegate(q: str) -> str: + return (await researcher.run(q)).output + + supervisor.run_sync("go") + rows = emitted() + + assert len({row["session_id"] for row in rows}) == 1 + starts = {row["agent_id"]: row for row in of_type(rows, "agent_start")} + assert set(starts) == {"supervisor", "researcher"} + assert starts["supervisor"].get("parent_id") is None + assert starts["researcher"]["parent_id"] == "supervisor" + # Every event carries the agent_id of an agent whose agent_start is open — + # otherwise the dashboard synthesizes a never-ending root span. + assert {row["agent_id"] for row in rows} == {"supervisor", "researcher"} + + +def test_the_conversation_id_becomes_the_session_id(instrumented, emitted): + agent = Agent(TestModel(call_tools=[]), name="chatty") + agent.run_sync("first", conversation_id="conversation-42") + agent.run_sync("second", conversation_id="conversation-42") + rows = emitted() + + # A conversation spanning several runs is ONE Failproof AI session; two runs are + # two agent spans inside it. + assert {row["session_id"] for row in rows} == {"conversation-42"} + assert len(of_type(rows, "agent_start")) == 2 + + +# --------------------------------------------------------------------------- +# The adapter must never break the host agent +# --------------------------------------------------------------------------- + +class Boom: + """Every attribute is a callable that raises.""" + + def __getattr__(self, name): + def explode(*args, **kwargs): + raise RuntimeError(f"translator exploded in {name}") + + return explode + + +def test_a_translator_that_raises_on_every_call_leaves_the_run_intact( + instrumented, emitted, monkeypatch +): + monkeypatch.setattr(adapter, "_tracker", Boom()) + + result = weather_agent_with_tool().run_sync("weather in london?") + + assert result.output == "done", "the adapter changed what the framework returned" + assert emitted() == [], "a broken translator still managed to emit" + + +def test_a_failing_capability_injection_does_not_break_agent_construction( + instrumented, emitted, monkeypatch +): + def explode(kwargs): + raise RuntimeError("injection exploded") + + monkeypatch.setattr(adapter, "_inject", explode) + + # Construction still succeeds and the run is untouched; it simply records + # nothing, because the capability never got attached. + assert weather_agent_with_tool().run_sync("go").output == "done" + assert emitted() == [] + + +def test_strict_mode_turns_the_swallow_into_a_raise(instrumented, monkeypatch): + """Without this, "it didn't crash" is the only provable property. + + FAILPROOFAI_SDK_STRICT=1 is what makes the never-raise policy testable at all — and + it doubles as the production switch for debugging an adapter gone quiet. + """ + monkeypatch.setattr(adapter, "_tracker", Boom()) + _core.set_strict(True) + try: + with pytest.raises(RuntimeError, match="translator exploded"): + weather_agent_with_tool().run_sync("go") + finally: + _core.set_strict(False) + _core.reset_failures() + + +# --------------------------------------------------------------------------- +# Install / uninstall discipline +# --------------------------------------------------------------------------- + +def test_install_patches_agent_init_and_uninstall_restores_the_saved_object(): + original = Agent.__init__ + failproofai_sdk.instrument("pydantic_ai") + try: + assert Agent.__init__ is not original + assert _core.is_wrapped(Agent.__init__) + # The SAVED object, never a re-import: re-importing to restore hands back + # whatever the attribute's source currently holds, which is how two + # instrumentation libraries silently un-patch each other. + assert _core.unwrap(Agent.__init__) is original + finally: + failproofai_sdk.uninstrument("pydantic_ai") + assert Agent.__init__ is original + + +def test_instrumenting_twice_is_a_no_op(): + failproofai_sdk.instrument("pydantic_ai") + try: + assert failproofai_sdk.instrument("pydantic_ai") == () + finally: + failproofai_sdk.uninstrument("pydantic_ai") + + +def test_an_explicit_capability_is_not_duplicated(instrumented, emitted): + agent = Agent(TestModel(call_tools=[]), name="explicit", capabilities=[FailproofAI()]) + agent.run_sync("go") + + assert types_of(emitted()) == ["agent_start", "model_request", "model_response", "agent_end"] + + +def test_an_agent_built_while_instrumented_goes_inert_after_uninstrument(emitted): + failproofai_sdk.instrument("pydantic_ai") + agent = weather_agent_with_tool() + failproofai_sdk.uninstrument("pydantic_ai") + + # We cannot retro-remove the capability object from an already-built agent, + # so uninstall() makes it a pass-through instead. Anything less means + # uninstrument() does not actually stop the recording. + assert agent.run_sync("go").output == "done" + assert emitted() == [] + + +def test_unknown_options_are_ignored_rather_than_fatal(emitted): + # `failproofai_sdk.instrument()` with no name fans the same **options out to every + # detected adapter, so a keyword meant for LangChain must not take this one + # down. + _core.set_strict(True) + try: + assert failproofai_sdk.instrument("pydantic_ai", langgraph_node_hooks=False) == ("pydantic_ai",) + finally: + failproofai_sdk.uninstrument("pydantic_ai") + _core.set_strict(None) + + +# --------------------------------------------------------------------------- +# Structural anti-drift — the test that catches a silent upstream rename +# --------------------------------------------------------------------------- + +OVERRIDES = { + name: obj + for name, obj in vars(FailproofAI).items() + if inspect.isfunction(obj) and not name.startswith("_") +} + + +class TestAntiDrift: + """Reflection over the REAL base class. + + If upstream renames a callback, our override becomes dead code that is never + called — the framework keeps working, every fake-based test above keeps + passing, and we record nothing. Nothing but reflection catches that. + """ + + def test_the_override_set_is_not_empty(self): + # Guards every test below: `for name in {}` passes vacuously. + assert set(OVERRIDES) == { + "get_ordering", + "wrap_run", + "wrap_model_request", + "wrap_tool_execute", + } + + @pytest.mark.parametrize("name", sorted(OVERRIDES)) + def test_each_override_still_exists_on_the_base_class(self, name): + assert hasattr(AbstractCapability, name), ( + f"FailproofAI.{name} no longer overrides anything on AbstractCapability — " + "it is dead code that will never be called." + ) + + @pytest.mark.parametrize("name", sorted(OVERRIDES)) + def test_each_override_actually_replaces_the_base_implementation(self, name): + assert getattr(AbstractCapability, name) is not OVERRIDES[name] + + @pytest.mark.parametrize("name", sorted(OVERRIDES)) + def test_every_parameter_we_declare_still_exists_on_the_base_signature(self, name): + ours = inspect.signature(OVERRIDES[name]).parameters + theirs = inspect.signature(getattr(AbstractCapability, name)).parameters + for parameter in ours.values(): + if parameter.kind in ( + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + ): + continue + assert parameter.name in theirs, ( + f"FailproofAI.{name} declares {parameter.name!r}, which " + f"AbstractCapability.{name} no longer accepts" + ) + assert parameter.kind == theirs[parameter.name].kind, ( + f"FailproofAI.{name} takes {parameter.name!r} as {parameter.kind}, " + f"the base now takes it as {theirs[parameter.name].kind}" + ) + + @pytest.mark.parametrize("name", sorted(OVERRIDES)) + def test_every_hook_we_override_is_still_async_where_the_base_is(self, name): + # A `def` where the base has `async def` produces a coroutine the + # framework awaits into a TypeError — or worse, never awaits at all. + assert inspect.iscoroutinefunction(OVERRIDES[name]) == inspect.iscoroutinefunction( + getattr(AbstractCapability, name) + ) + + def test_agent_init_still_takes_a_keyword_only_capabilities_argument(self): + # This is the install mechanism. If `capabilities` stops being a + # keyword-only parameter of Agent.__init__, `install()` silently attaches + # nothing at all. + parameter = inspect.signature(Agent.__init__).parameters["capabilities"] + assert parameter.kind is inspect.Parameter.KEYWORD_ONLY + + def test_there_is_still_no_supported_global_capability_default(self): + # If upstream ever grows a public one, `install()` should stop patching + # `Agent.__init__` and use it — patching a constructor cannot reach + # agents that already exist. + assert not hasattr(Agent, "capabilities_all") + assert not hasattr(Agent, "instrument_all_capabilities") + + @pytest.mark.parametrize( + "attribute", + ["run_id", "conversation_id", "prompt", "agent", "run_step", "metadata", "usage"], + ) + def test_run_context_still_carries_the_fields_we_read(self, attribute): + assert attribute in inspect.get_annotations(RunContext, eval_str=False) + + @pytest.mark.parametrize( + "attribute", ["model", "messages", "model_request_parameters", "streaming", "model_id"] + ) + def test_model_request_context_still_carries_the_fields_we_read(self, attribute): + assert attribute in inspect.get_annotations(ModelRequestContext, eval_str=False) + + @pytest.mark.parametrize("attribute", ["input_tokens", "output_tokens"]) + def test_usage_still_uses_the_2_0_token_names(self, attribute): + # 2.0 renamed request_tokens/response_tokens. Reading the old names would + # report zero tokens on every event, at HTTP 200. + assert hasattr(RunUsage(), attribute) + assert hasattr(RequestUsage(), attribute) + + @pytest.mark.parametrize("attribute", ["tool_name", "tool_call_id", "args"]) + def test_tool_call_part_still_carries_the_fields_we_read(self, attribute): + assert hasattr(ToolCallPart("t", {}, tool_call_id="x"), attribute) + + def test_at_least_one_control_flow_exception_is_still_recognised(self): + # If every name in the list disappeared, `_CONTROL_FLOW` would go empty + # and control flow would start being reported as run failures. + assert adapter._CONTROL_FLOW + + def test_we_deliberately_do_not_override_the_two_hooks_with_side_effects(self): + capability = FailproofAI() + # Overriding wrap_run_event_stream makes `agent.run()` switch itself into + # streaming mode; overriding wrap_node_run flips has_wrap_node_run and + # buys nothing but a doubled row count. + assert capability.has_wrap_run_event_stream is False + assert capability.has_wrap_node_run is False + + def test_the_capability_is_constructible_and_orders_itself_outermost(self): + ordering = FailproofAI().get_ordering() + assert ordering.position == "outermost" diff --git a/sdk/python/tests/test_context.py b/sdk/python/tests/test_context.py new file mode 100644 index 000000000..85596a6e1 --- /dev/null +++ b/sdk/python/tests/test_context.py @@ -0,0 +1,410 @@ +"""Tests for the identity layer: contextvars, the agent stack, and propagate(). + +The tests that matter here are the ones a reviewer cannot derive from reading +the diff: + +* the **adversarially interleaved** task test, which is the only shape that + fails against a module-global implementation; +* the **tuple test**, which is the only shape that fails against + `ContextVar[list]`; +* `propagate` under `pool.map`, which is the only shape that fails against + `partial(copy_context().run, fn)`. +""" + +import asyncio +import threading +from concurrent.futures import ThreadPoolExecutor + +import pytest + +import failproofai_sdk +import failproofai_sdk._context as _context +import failproofai_sdk._runtime as _runtime + + +# --------------------------------------------------------------------------- +# Basic binding +# --------------------------------------------------------------------------- + +def test_nothing_bound_by_default(): + ident = failproofai_sdk.current() + assert ident.session_id is None + assert ident.agent_id is None + assert ident.parent_id is None + assert ident.depth == 0 + + +def test_session_binds_and_unbinds(): + with failproofai_sdk.session("s-1") as sid: + assert sid == "s-1" + assert failproofai_sdk.current().session_id == "s-1" + assert failproofai_sdk.current().session_id is None + + +def test_session_generates_an_id_when_none_bound(): + with failproofai_sdk.session() as sid: + assert isinstance(sid, str) and len(sid) == 32 + assert failproofai_sdk.current().session_id == sid + + +def test_nested_session_inherits_rather_than_generating(events): + with failproofai_sdk.session() as outer: + with failproofai_sdk.session() as inner: + assert inner == outer + with failproofai_sdk.agent("child") as ident: + assert ident.session_id == outer + + +def test_agent_id_falls_back_to_main(): + assert _context.agent_id() == _context.DEFAULT_AGENT_ID == "main" + with failproofai_sdk.session("s-1", agent_id="worker"): + assert _context.agent_id() == "worker" + + +# --------------------------------------------------------------------------- +# Task isolation — must FAIL against a module-global implementation +# --------------------------------------------------------------------------- + +def test_tasks_are_isolated_when_adversarially_interleaved(events): + """Two tasks bind different sessions, then are forced to interleave. + + The `await asyncio.sleep(0)` between binding and emitting is the whole + point: it yields to the event loop *while the scope is open*, so task B + binds its session before task A emits. Against a module global, A's + tool_use would carry B's session_id and this assertion fails. Without the + forced interleave the same test passes on a global and proves nothing. + """ + order: list[str] = [] + + async def run(name: str) -> None: + async with failproofai_sdk.agent(name, session_id=f"sess-{name}"): + order.append(f"bound-{name}") + await asyncio.sleep(0) + await asyncio.sleep(0) + order.append(f"emit-{name}") + _runtime.event.tool_use(tool_name="t", tool_call_id=f"tc-{name}") + await asyncio.sleep(0) + + async def main() -> None: + await asyncio.gather(run("a"), run("b")) + + asyncio.run(main()) + + # Prove the interleave actually happened; otherwise the test is vacuous. + assert order.index("bound-b") < order.index("emit-a") + + by_call = {e["tool_call_id"]: e for e in events.entries if e["type"] == "tool_use"} + assert by_call["tc-a"]["session_id"] == "sess-a" + assert by_call["tc-a"]["agent_id"] == "a" + assert by_call["tc-b"]["session_id"] == "sess-b" + assert by_call["tc-b"]["agent_id"] == "b" + + +def test_agent_stack_is_a_tuple_not_a_shared_list(events): + """Task A's stack must be unmodified by task B pushing a nested agent. + + A `ContextVar[list]` holds the *same list object* in both tasks, so B's + push would show up in A's view. This is the test that catches it. + """ + seen: dict[str, tuple[str, ...]] = {} + pushed = asyncio.Event() + + async def first() -> None: + async with failproofai_sdk.agent("root-a", session_id="s"): + async with failproofai_sdk.agent("child-a"): + # Hand control to the other task, which pushes child-b. + await pushed.wait() + seen["a"] = _context._AGENT_STACK.get() + + async def second() -> None: + async with failproofai_sdk.agent("root-b", session_id="s"): + async with failproofai_sdk.agent("child-b"): + seen["b"] = _context._AGENT_STACK.get() + pushed.set() + await asyncio.sleep(0) + + async def main() -> None: + await asyncio.gather(first(), second()) + + asyncio.run(main()) + + assert seen["a"] == ("root-a", "child-a") + assert seen["b"] == ("root-b", "child-b") + assert isinstance(seen["a"], tuple) + + +def test_create_task_inherits_without_propagate(events): + """Pinned deliberately: contextvars DO flow into asyncio tasks. + + Nobody should later sprinkle `propagate()` over async code — a Task copies + the current context at creation, so the identity is already there. + """ + captured: list = [] + + async def child() -> None: + captured.append(failproofai_sdk.current()) + + async def main() -> None: + with failproofai_sdk.agent("parent", session_id="s-async"): + await asyncio.create_task(child()) + + asyncio.run(main()) + assert captured[0].session_id == "s-async" + assert captured[0].agent_id == "parent" + + +# --------------------------------------------------------------------------- +# Thread isolation and propagate() +# --------------------------------------------------------------------------- + +def test_threads_do_not_inherit_identity(): + seen: list = [] + + def worker() -> None: + seen.append(failproofai_sdk.current()) + + with failproofai_sdk.session("s-thread"): + t = threading.Thread(target=worker) + t.start() + t.join() + + assert seen[0].session_id is None + + +def test_bare_submit_inside_agent_raises_naming_propagate(events): + def worker(): + _runtime.event.tool_use(tool_name="t", tool_call_id="tc") + + with failproofai_sdk.agent("a", session_id="s"): + with ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(worker) + with pytest.raises(TypeError) as excinfo: + future.result() + + assert "propagate" in str(excinfo.value) + + +def test_propagate_with_pool_submit(events): + def worker(n): + _runtime.event.tool_use(tool_name="t", tool_call_id=f"tc-{n}") + return failproofai_sdk.current().session_id + + with failproofai_sdk.agent("a", session_id="s-submit"): + with ThreadPoolExecutor(max_workers=2) as pool: + results = [pool.submit(failproofai_sdk.propagate(worker), n).result() for n in range(3)] + + assert results == ["s-submit"] * 3 + tool_uses = [e for e in events.entries if e["type"] == "tool_use"] + assert {e["session_id"] for e in tool_uses} == {"s-submit"} + assert {e["agent_id"] for e in tool_uses} == {"a"} + + +def test_propagate_survives_reuse_via_pool_map(events): + """`pool.map` calls ONE wrapper object many times, concurrently. + + This is what `partial(contextvars.copy_context().run, fn)` cannot do: a + `Context` raises `RuntimeError: cannot enter context ... already entered` + on the second concurrent entry, and mutations inside it persist into the + next call. Snapshot/restore has neither problem. + """ + # Forces four workers to be *inside* the wrapper simultaneously. Without + # it the calls serialise and a copy_context() implementation passes by + # accident, which makes the test worthless. + barrier = threading.Barrier(4) + + def worker(n): + ident = failproofai_sdk.current() + barrier.wait(timeout=5) + # Mutate the stack, to prove the mutation does not leak into the next + # invocation of the same wrapper. + with failproofai_sdk.agent(f"child-{n}", session_id=ident.session_id): + pass + return (ident.session_id, ident.agent_id, ident.depth) + + with failproofai_sdk.agent("a", session_id="s-map"): + wrapped = failproofai_sdk.propagate(worker) + with ThreadPoolExecutor(max_workers=4) as pool: + results = list(pool.map(wrapped, range(8))) + + assert results == [("s-map", "a", 1)] * 8 + + +def test_propagate_with_threading_thread(events): + seen: list = [] + + def worker(): + seen.append(failproofai_sdk.current()) + + with failproofai_sdk.agent("a", session_id="s-thread2"): + t = threading.Thread(target=failproofai_sdk.propagate(worker)) + t.start() + t.join() + + assert seen[0].session_id == "s-thread2" + assert seen[0].agent_id == "a" + + +def test_propagate_with_run_in_executor(events): + def worker(): + return failproofai_sdk.current().session_id + + async def main(): + with failproofai_sdk.agent("a", session_id="s-executor"): + loop = asyncio.get_running_loop() + with ThreadPoolExecutor(max_workers=1) as pool: + return await loop.run_in_executor(pool, failproofai_sdk.propagate(worker)) + + assert asyncio.run(main()) == "s-executor" + + +def test_propagate_does_not_leak_into_the_worker_thread_afterwards(): + seen: list = [] + + def worker(): + pass + + def after(): + seen.append(failproofai_sdk.current().session_id) + + with failproofai_sdk.session("s-leak"): + wrapped = failproofai_sdk.propagate(worker) + with ThreadPoolExecutor(max_workers=1) as pool: + pool.submit(wrapped).result() + pool.submit(after).result() + + assert seen[0] is None + + +def test_propagate_preserves_metadata(): + def worker(a, b=2): + """docstring.""" + return a + b + + wrapped = failproofai_sdk.propagate(worker) + assert wrapped.__name__ == "worker" + assert wrapped.__doc__ == "docstring." + assert wrapped(1, b=3) == 4 + + +# --------------------------------------------------------------------------- +# Explicit identity always wins +# --------------------------------------------------------------------------- + +def test_explicit_ids_override_the_context(events): + with failproofai_sdk.agent("ctx-agent", session_id="ctx-session"): + _runtime.event.tool_use( + session_id="explicit-session", + agent_id="explicit-agent", + tool_name="t", + tool_call_id="tc", + ) + entry = [e for e in events.entries if e["type"] == "tool_use"][0] + assert entry["session_id"] == "explicit-session" + assert entry["agent_id"] == "explicit-agent" + + +def test_explicit_agent_id_only_keeps_context_session(events): + with failproofai_sdk.agent("ctx-agent", session_id="ctx-session"): + _runtime.event.tool_use(agent_id="other", tool_name="t", tool_call_id="tc") + entry = [e for e in events.entries if e["type"] == "tool_use"][0] + assert entry["session_id"] == "ctx-session" + assert entry["agent_id"] == "other" + + +def test_missing_session_outside_any_scope_raises(events): + with pytest.raises(TypeError, match="propagate"): + _runtime.event.agent_start() + + +def test_agent_id_defaults_to_main_with_only_a_session(events): + with failproofai_sdk.session("s-only"): + _runtime.event.agent_start() + assert events.last()["agent_id"] == "main" + + +# --------------------------------------------------------------------------- +# Exception precedence is unchanged by the identity fallback +# --------------------------------------------------------------------------- + +def test_duration_ms_guard_still_beats_the_identity_error(events): + """No session bound, and duration_ms passed. The ValueError must win. + + `_identity()` runs after the guard precisely so today's precedence holds. + """ + with pytest.raises(ValueError, match="duration_ms"): + _runtime.event.tool_result(tool_name="t", tool_call_id="tc", duration_ms=99) + + +def test_reserved_field_error_still_beats_the_identity_error(events): + with pytest.raises(ValueError, match="Reserved field"): + _runtime.event.agent_start(timestamp="nope") + + +def test_duplicate_session_id_still_raises_type_error(events): + with pytest.raises(TypeError): + _runtime.event.agent_start(session_id="s", **{"session_id": "s2"}) + + +# --------------------------------------------------------------------------- +# `_pending` correlation keys +# --------------------------------------------------------------------------- + +def test_hook_id_no_longer_collides_with_tool_call_id(events): + ns = _runtime.event + ns.tool_use(session_id="s", agent_id="a", tool_name="t", tool_call_id="x1") + ns.hook_completed(session_id="s", agent_id="a", hook_name="h", hook_id="x1") + assert "duration_ms" not in events.last() + # And the tool's own pending entry is untouched, so it still pairs. + ns.tool_result(session_id="s", agent_id="a", tool_name="t", tool_call_id="x1") + assert "duration_ms" in events.last() + + +def test_hook_pair_still_produces_a_duration(events): + ns = _runtime.event + ns.hook_triggered(session_id="s", agent_id="a", hook_name="h", hook_id="h1") + ns.hook_completed(session_id="s", agent_id="a", hook_name="h", hook_id="h1") + assert "duration_ms" in events.last() + + +def test_tool_keys_stay_bare_so_cross_agent_pairs_still_match(events): + """A tool started under one agent and finished under another must pair. + + Namespacing tool keys by agent_id would silently turn this correct + duration_ms into a missing one, and this is routine once frameworks run + tools inside sub-agents. + """ + ns = _runtime.event + ns.tool_use(session_id="s", agent_id="planner", tool_name="t", tool_call_id="tc") + ns.tool_result(session_id="s", agent_id="worker", tool_name="t", tool_call_id="tc") + assert "duration_ms" in events.last() + + +def test_track_pending_is_thread_safe_at_the_cap(events): + """Hammer `_track_pending` at the cap from several threads. + + Unlocked, the read-modify-write lets two threads pick the same `oldest` and + the second `del` raises KeyError *inside* the caller's event.tool_use(). + """ + from failproofai_sdk._events import _PENDING_CAP + + ns = _runtime.event + for i in range(_PENDING_CAP): + ns._track_pending(f"seed-{i}", ns._now()) + + errors: list[Exception] = [] + + def hammer(worker: int) -> None: + try: + for i in range(200): + ns._track_pending(f"w{worker}-{i}", ns._now()) + except Exception as exc: + errors.append(exc) + + threads = [threading.Thread(target=hammer, args=(w,)) for w in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert errors == [] + assert len(ns._pending) == _PENDING_CAP diff --git a/sdk/python/tests/test_docs.py b/sdk/python/tests/test_docs.py new file mode 100644 index 000000000..4fbd936a8 --- /dev/null +++ b/sdk/python/tests/test_docs.py @@ -0,0 +1,411 @@ +"""`docs/` is the integration surface, so it is code under test. + +The guide and the runnable code live together — one directory per framework, +each holding the README somebody reads and the `examples/` they run: + + docs/ + README.md the index + _shared/ cosmetics for the examples; never part of the SDK + manual/ no framework, raw scopes + event.* + langgraph/ crewai/ llama_index/ pydantic_ai/ + README.md the guide + examples/*.py runnable, and run before shipping + +Nothing here checks prose. It checks the three ways a docs tree rots without +anybody noticing: a framework gains an adapter and never gains a page, a page or +an example names an API that no longer exists, and an example drifts into +teaching the manual identity path the scopes replaced. + +The examples themselves need a framework and an API key, so they cannot run in +unit CI — which is exactly why they need a guard that does not. +""" +from __future__ import annotations + +import ast +import re +import textwrap +from pathlib import Path + +import pytest + +import failproofai_sdk + +DOCS = Path(failproofai_sdk.__file__).resolve().parent.parent / "docs" + +#: adapter registry name -> the directory that documents it. +#: `langgraph` is the directory for the `langchain` adapter: it is the spelling +#: people search for, and the adapter serves both. +ADAPTER_DIRS = { + "langchain": "langgraph", + "crewai": "crewai", + "llama_index": "llama_index", + "pydantic_ai": "pydantic_ai", +} + +#: directory -> the failproofai-sdk extra its guide must name. +DIR_EXTRA = { + "langgraph": "langgraph", + "crewai": "crewai", + "llama_index": "llamaindex", + "pydantic_ai": "pydantic-ai", +} + +#: directory -> third-party modules its examples may import. `manual/` maps to +#: the openai client and nothing else: it is the "no framework" path, so +#: borrowing one would defeat the point of it. +DIR_IMPORTS = { + "langgraph": {"langchain_core", "langgraph", "langchain_openai"}, + "crewai": {"crewai"}, + "llama_index": {"llama_index"}, + "pydantic_ai": {"pydantic_ai", "pydantic"}, + "manual": {"openai"}, +} + +#: stdlib and local helpers every example may import. `_shared` is the trace +#: printer and is explicitly not part of the SDK surface. +ALWAYS_ALLOWED = {"failproofai_sdk", "_shared", "os", "sys", "json", "asyncio", "pathlib"} + +ALL_DIRS = sorted(DIR_IMPORTS) + + +def _guides() -> list[Path]: + """The framework guides. `_shared/README.md` documents the trace printer, + which is cosmetics rather than an integration, so it is not one of these.""" + return sorted((DOCS / d / "README.md") for d in ALL_DIRS) + + +def _examples() -> list[Path]: + return sorted(p for d in ALL_DIRS for p in (DOCS / d / "examples").glob("*.py")) + + +EXAMPLES = _examples() +EXAMPLE_IDS = [f"{p.parents[1].name}/{p.name}" for p in EXAMPLES] + + +def _imports(tree: ast.AST) -> set[str]: + out: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + out |= {a.name.split(".")[0] for a in node.names} + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + out.add(node.module.split(".")[0]) + return out + + +# ───────────────────────────────────────────────────────────────────────────── +# Shape +# ───────────────────────────────────────────────────────────────────────────── + + +def test_the_docs_tree_exists(): + assert DOCS.is_dir(), f"{DOCS} is missing" + assert (DOCS / "README.md").is_file(), "docs/README.md — the index — is missing" + + +def test_there_is_a_directory_for_every_adapter(): + """An adapter with no guide is one nobody will find.""" + from failproofai_sdk.integrations import _REGISTRY + + assert set(ADAPTER_DIRS) == set(_REGISTRY), ( + f"adapters {sorted(_REGISTRY)} but guides for {sorted(ADAPTER_DIRS)}" + ) + for adapter, directory in ADAPTER_DIRS.items(): + assert (DOCS / directory).is_dir(), f"{adapter} has no docs/{directory}/" + + +def test_every_documented_directory_is_accounted_for(): + """A directory nobody links to is a directory nobody maintains.""" + found = {p.name for p in DOCS.iterdir() if p.is_dir() and p.name != "_shared"} + assert found == set(ALL_DIRS) + + +@pytest.mark.parametrize("directory", ALL_DIRS) +def test_a_directory_has_a_guide_and_examples(directory): + assert (DOCS / directory / "README.md").is_file(), f"{directory}/README.md missing" + examples = list((DOCS / directory / "examples").glob("*.py")) + assert examples, f"{directory}/examples/ has no runnable code" + + +@pytest.mark.parametrize("directory", ALL_DIRS) +def test_a_directory_has_a_quickstart(directory): + """`quickstart.py` is the fixed entry point every guide links to.""" + assert (DOCS / directory / "examples" / "quickstart.py").is_file() + + +@pytest.mark.parametrize("directory", ALL_DIRS) +def test_a_directory_has_more_than_a_quickstart(directory): + """A quickstart alone never shows a multi-event flow, which is the point.""" + assert len(list((DOCS / directory / "examples").glob("*.py"))) >= 2 + + +def test_the_index_links_to_every_directory(): + index = (DOCS / "README.md").read_text(encoding="utf-8") + for directory in ALL_DIRS: + assert f"{directory}/" in index, f"docs/README.md never links to {directory}/" + + +# ───────────────────────────────────────────────────────────────────────────── +# The guides +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("guide", _guides(), ids=lambda p: p.parent.name) +def test_a_guide_only_names_api_this_package_exports(guide): + """A rename in the SDK must break this test, not somebody's copy-paste.""" + text = guide.read_text(encoding="utf-8") + referenced = set(re.findall(r"failproofai_sdk\.([a-z_]+)\s*\(", text)) - {"event"} + missing = {name for name in referenced if not hasattr(failproofai_sdk, name)} + assert not missing, f"{guide.parent.name} names failproofai_sdk.{missing}, which does not exist" + + +@pytest.mark.parametrize("guide", _guides(), ids=lambda p: p.parent.name) +def test_a_guide_only_names_event_methods_that_exist(guide): + text = guide.read_text(encoding="utf-8") + referenced = set(re.findall(r"failproofai_sdk\.event\.([a-z_]+)\s*\(", text)) + missing = {n for n in referenced if not hasattr(failproofai_sdk.event, n)} + assert not missing, f"{guide.parent.name} names event.{missing}, which does not exist" + + +@pytest.mark.parametrize("guide", _guides() + [DOCS / "README.md"], ids=lambda p: str(p.parent.name or "index")) +def test_a_guide_only_names_extras_that_exist(guide): + """A wrong extra in an install line is a five-minute dead end.""" + try: # Python 3.11+ + import tomllib + except ModuleNotFoundError: + import tomli as tomllib + + manifest = tomllib.loads((DOCS.parent / "pyproject.toml").read_text(encoding="utf-8")) + extras = set(manifest["project"]["optional-dependencies"]) + named = set(re.findall(r"failproofai-sdk\[([a-z-]+)\]", guide.read_text(encoding="utf-8"))) + assert named <= extras, f"{guide} names non-existent extras: {sorted(named - extras)}" + + +@pytest.mark.parametrize("directory,extra", sorted(DIR_EXTRA.items())) +def test_a_framework_guide_names_its_own_extra(directory, extra): + text = (DOCS / directory / "README.md").read_text(encoding="utf-8") + assert "pip install" in text, f"{directory} never says how to install it" + assert f"failproofai-sdk[{extra}]" in text, f"{directory} does not name the {extra!r} extra" + + +@pytest.mark.parametrize("directory", sorted(DIR_EXTRA)) +def test_a_framework_guide_shows_instrument_and_a_session(directory): + text = (DOCS / directory / "README.md").read_text(encoding="utf-8") + assert "failproofai_sdk.instrument(" in text, f"{directory} never shows instrument()" + assert "failproofai_sdk.session()" in text, f"{directory} never shows a session" + + +@pytest.mark.parametrize("guide", _guides(), ids=lambda p: p.parent.name) +def test_a_guide_links_to_the_examples_it_documents(guide): + """A guide describing a file that does not exist is worse than no guide.""" + text = guide.read_text(encoding="utf-8") + linked = set(re.findall(r"\(examples/([\w.]+\.py)\)", text)) + on_disk = {p.name for p in (guide.parent / "examples").glob("*.py")} + assert linked, f"{guide.parent.name} links to none of its examples" + assert linked <= on_disk, f"{guide.parent.name} links to missing {sorted(linked - on_disk)}" + + +def test_the_manual_guide_does_not_teach_instrument(): + """`manual/` is the no-adapter path; showing instrument() there is a slip. + + Scoped to the part of the page that teaches the manual API — the section on + unsupported frameworks legitimately contrasts the two. + """ + text = (DOCS / "manual" / "README.md").read_text(encoding="utf-8") + body = text.split("## Instrumenting an unsupported framework")[0] + assert "failproofai_sdk.instrument()" not in body + + +def test_the_index_lists_every_event_type(): + """The event matrix is the index's whole value; a missing row is a silent gap.""" + from failproofai_sdk._events import EventNamespace + + text = (DOCS / "README.md").read_text(encoding="utf-8") + methods = [ + n for n in vars(EventNamespace) + if not n.startswith("_") and callable(getattr(EventNamespace, n)) + ] + missing = [m for m in methods if m not in text] + assert not missing, f"docs/README.md does not mention {missing}" + + +# ───────────────────────────────────────────────────────────────────────────── +# The examples +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("path", EXAMPLES, ids=EXAMPLE_IDS) +def test_an_example_parses(path): + ast.parse(path.read_text(encoding="utf-8")) + + +@pytest.mark.parametrize("path", EXAMPLES, ids=EXAMPLE_IDS) +def test_an_example_imports_only_its_own_framework(path): + """Borrowing a second framework turns an example into an install problem.""" + tree = ast.parse(path.read_text(encoding="utf-8")) + allowed = ALWAYS_ALLOWED | DIR_IMPORTS[path.parents[1].name] + unexpected = _imports(tree) - allowed + assert not unexpected, f"{path.parents[1].name}/{path.name} imports {sorted(unexpected)}" + + +@pytest.mark.parametrize("path", EXAMPLES, ids=EXAMPLE_IDS) +def test_an_example_calls_api_this_package_exports(path): + called = { + node.func.attr + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "failproofai_sdk" + } + assert called, f"{path.name} never calls failproofai_sdk.*" + missing = {c for c in called if not hasattr(failproofai_sdk, c)} + assert not missing, f"{path.name} calls failproofai_sdk.{missing} which does not exist" + + +@pytest.mark.parametrize("path", EXAMPLES, ids=EXAMPLE_IDS) +def test_an_example_does_not_thread_identity_by_hand(path): + """`session_id=` everywhere teaches the path the scopes were built to remove. + + Checked over the AST rather than the raw text, so an example is free to + *explain* `session_id=` in its docstring — which the manual quickstart has + to, since explaining what the scopes replace is its whole job. + """ + threaded = [ + node + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))) + if isinstance(node, ast.Call) + for kw in node.keywords + if kw.arg in ("session_id", "agent_id") + ] + assert not threaded, ( + f"{path.parents[1].name}/{path.name} passes session_id/agent_id by hand" + ) + + +@pytest.mark.parametrize("path", EXAMPLES, ids=EXAMPLE_IDS) +def test_an_example_opens_a_session(path): + source = path.read_text(encoding="utf-8") + assert "failproofai_sdk.session()" in source, f"{path.name} never opens a session" + + +@pytest.mark.parametrize( + "path", + [p for p in EXAMPLES if p.parents[1].name != "manual"], + ids=[i for i in EXAMPLE_IDS if not i.startswith("manual/")], +) +def test_a_framework_example_instruments(path): + source = path.read_text(encoding="utf-8") + assert "failproofai_sdk.instrument()" in source, f"{path.name} never instruments" + + +@pytest.mark.parametrize( + "path", + [p for p in EXAMPLES if p.parents[1].name == "manual"], + ids=[i for i in EXAMPLE_IDS if i.startswith("manual/")], +) +def test_a_manual_example_does_not_instrument(path): + """`manual/` exists to show the path with no adapter.""" + source = path.read_text(encoding="utf-8") + assert "failproofai_sdk.instrument()" not in source + + +@pytest.mark.parametrize("path", EXAMPLES, ids=EXAMPLE_IDS) +def test_an_example_documents_how_to_run_itself(path): + """The docstring is what somebody reads before running it.""" + tree = ast.parse(path.read_text(encoding="utf-8")) + doc = ast.get_docstring(tree) or "" + assert "pip install" in doc, f"{path.name} does not say how to install it" + expected = f"python docs/{path.parents[1].name}/examples/{path.name}" + assert expected in doc, f"{path.name} does not show `{expected}`" + + +def test_the_shared_helper_is_not_importable_from_the_sdk(): + """`_shared` is cosmetics. If it ever became a dependency of the package, + the zero-dependency promise would be quietly routed around.""" + import failproofai_sdk.integrations as integrations + + for module in (failproofai_sdk, integrations): + source = Path(module.__file__).read_text(encoding="utf-8") + assert "_shared" not in source + + +# ───────────────────────────────────────────────────────────────────────────── +# Documented options must exist +# ───────────────────────────────────────────────────────────────────────────── +# +# The guides shipped naming `capture_content` for CrewAI and `session_id` for +# LlamaIndex. Neither adapter reads either one, so both were silently ignored: +# `instrument()` passes the same dict to every adapter and unknown keys are +# dropped by design, which means a reader following the docs got no error and no +# effect. Nothing checked, because the option lists were prose. + +#: docs directory -> the adapter module that backs it. +DIR_ADAPTER = { + "langgraph": "langchain", + "crewai": "crewai", + "llama_index": "llama_index", + "pydantic_ai": "pydantic_ai", +} + + +def _adapter_options(module_name: str) -> set[str]: + """Every option key an adapter actually reads, from its source. + + Two spellings, because the adapters use two: `options.get("x")` for the ones + that read the dict directly, and a dataclass of defaults for LangChain, + which parses the dict into `_Options` first. + """ + import failproofai_sdk + + source = ( + Path(failproofai_sdk.__file__).resolve().parent + / "integrations" + / f"{module_name}.py" + ).read_text(encoding="utf-8") + + names = set(re.findall(r'options\.get\(\s*["\'](\w+)["\']', source)) + + tree = ast.parse(source) + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef) and node.name == "_Options": + names |= { + stmt.target.id + for stmt in node.body + if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name) + } + return names + + +def _documented_options(text: str, adapter_name: str) -> set[str]: + """Keyword names inside a documented `instrument("<name>", ...)` call.""" + documented: set[str] = set() + for block in re.findall(r"```python[^\n]*\n(.*?)```", text, re.S): + try: + tree = ast.parse(textwrap.dedent(block)) + except SyntaxError: # a fragment, not a whole program + continue + for node in ast.walk(tree): + if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)): + continue + if node.func.attr != "instrument": + continue + first = node.args[0] if node.args else None + if isinstance(first, ast.Constant) and first.value == adapter_name: + documented |= {kw.arg for kw in node.keywords if kw.arg} + return documented + + +@pytest.mark.parametrize("directory,module_name", sorted(DIR_ADAPTER.items())) +def test_a_guide_only_documents_options_the_adapter_reads(directory, module_name): + text = (DOCS / directory / "README.md").read_text(encoding="utf-8") + documented = _documented_options(text, module_name) + if not documented: + pytest.skip(f"{directory} documents no instrument() options") + real = _adapter_options(module_name) + invented = documented - real + assert not invented, ( + f"{directory}/README.md documents instrument({module_name!r}, ...) options " + f"{sorted(invented)} that the adapter never reads. Unknown keys are " + f"dropped silently, so a reader following this gets no error and no effect. " + f"Real options: {sorted(real)}" + ) diff --git a/sdk/python/tests/test_durability.py b/sdk/python/tests/test_durability.py new file mode 100644 index 000000000..0bc03655d --- /dev/null +++ b/sdk/python/tests/test_durability.py @@ -0,0 +1,1228 @@ +"""Nothing this SDK accepts may be lost without saying so. + +The writer buffers in memory and publishes from a background daemon thread, so +every failure here is asynchronous and out of the caller's sight: `event.*()` +returned None a long time ago and the application moved on. There is no return +code to check, no exception to catch, and — because an unread spool looks +exactly like an idle one — no symptom until someone notices a dashboard is +emptier than it should be. + +The tests below are the ones that would have caught a real loss. Several of them +are regression tests for a bug found while writing them: batches were named from +a millisecond timestamp alone, so two written in the same millisecond collided +and `os.replace` silently overwrote the first. It fired on the atexit flush +racing the flush thread, on `flush_now()` from two threads, and — worst — across +processes sharing one spool root, which is the ordinary deployment. +""" +import json +import logging +import os +import stat +import subprocess +import sys +import threading +import time +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +from failproofai_sdk import _resolver +from failproofai_sdk._events import _PENDING_CAP, EventNamespace, _tool_key +from failproofai_sdk._writer import _QUEUE_CAP, EventWriter + + +@pytest.fixture +def spool(tmp_path, monkeypatch): + """An isolated spool root, restored afterwards.""" + _resolver.set_base_dir(tmp_path) + yield tmp_path + _resolver.set_base_dir(None) + + +def read_all(spool_dir: Path) -> list[dict]: + """Every event in every published batch. Fails loudly on a torn file.""" + events = [] + for path in sorted((spool_dir / "events").glob("*.jsonl")): + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + try: + events.append(json.loads(line)) + except json.JSONDecodeError as exc: # pragma: no cover - failure path + pytest.fail(f"{path.name}:{lineno} is not valid JSON ({exc}): {line!r}") + return events + + +# ───────────────────────────────────────────────────────────────────────────── +# Batch naming — the silent-overwrite class +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.fixture +def frozen_clock(monkeypatch): + """Pin `_writer`'s clock so "the same millisecond" is guaranteed, not likely. + + Without this the collision tests below only reproduce the bug when both + writes happen to land in one millisecond. They usually do on a fast machine + — which is the problem: on a loaded CI runner the clock advances between + them, a timestamp-only stem produces two different names, and the test + passes against the very implementation it exists to reject. + """ + # NOT `from failproofai_sdk import _writer` — `__init__` binds that name to + # the EventWriter SINGLETON, which shadows the submodule of the same name on + # the package. Reaching for the module has to go through sys.modules. + writer_module = sys.modules["failproofai_sdk._writer"] + + fixed = datetime(2026, 1, 2, 3, 4, 5, 678_000, tzinfo=timezone.utc) + + class _FrozenDatetime(datetime): + @classmethod + def now(cls, tz=None): + return fixed + + monkeypatch.setattr(writer_module, "datetime", _FrozenDatetime) + return fixed + + +def test_two_batches_in_the_same_millisecond_do_not_overwrite_each_other(spool, frozen_clock): + """Regression: the timestamp-only stem lost whichever batch wrote first.""" + writer = EventWriter(flush_interval=3600) + writer._write_batch([{"id": "first"}]) + writer._write_batch([{"id": "second"}]) + + # Same instant for both, so a timestamp-only name could not have differed. + stems = sorted(p.name for p in (spool / "events").glob("*.jsonl")) + assert len(stems) == 2, f"the two batches collided onto one file: {stems}" + assert all(frozen_clock.strftime("%Y-%m-%dT%H-%M-%S") in s for s in stems) + + recovered = {e["id"] for e in read_all(spool)} + assert recovered == {"first", "second"} + + +# The DeprecationWarning about fork() in a multi-threaded process is the +# hazard under test, not a problem with the test. +@pytest.mark.filterwarnings("ignore:.*fork.*:DeprecationWarning") +def test_batch_filenames_are_unique_across_processes(spool, frozen_clock): + """Several agents share one spool root. Their batches must not collide. + + Nothing in a timestamp-only stem identified the writer, so two processes + flushing in the same millisecond overwrote each other — and because each one + saw its own `os.replace` succeed, both would report having written. + + The frozen clock is inherited across `fork()`, so every child computes the + same timestamp and the collision is forced rather than hoped for. + """ + if not hasattr(os, "fork"): + pytest.skip("requires fork") + + pids = [] + for _ in range(4): + pid = os.fork() + if pid == 0: # child + try: + writer = EventWriter(flush_interval=3600) + writer._write_batch([{"id": f"pid-{os.getpid()}"}]) + finally: + os._exit(0) + pids.append(pid) + for pid in pids: + _, status = os.waitpid(pid, 0) + assert os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0 + + assert len({e["id"] for e in read_all(spool)}) == 4 + + +def test_published_batches_never_end_in_tmp(spool): + """`.tmp` is how the daemons tell "still being written" from "ready".""" + writer = EventWriter(flush_interval=3600) + for i in range(20): + writer._write_batch([{"id": i}]) + + names = [p.name for p in (spool / "events").iterdir()] + assert names, "nothing was published" + assert all(n.endswith(".jsonl") for n in names) + assert not any(n.endswith(".tmp") for n in names), ( + f"a .tmp file survived publication: {[n for n in names if n.endswith('.tmp')]}" + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Concurrency +# ───────────────────────────────────────────────────────────────────────────── + + +def test_sixteen_threads_emitting_concurrently_lose_and_duplicate_nothing(spool): + """The queue is written by many threads and drained by one. Prove it holds.""" + threads, per_thread = 16, 500 + writer = EventWriter(flush_interval=3600) + namespace = EventNamespace(writer) + barrier = threading.Barrier(threads) + + def emit(thread_id: int): + barrier.wait() + for n in range(per_thread): + namespace.agent_start( + session_id=f"s{thread_id}", agent_id="a", goal=f"{thread_id}:{n}" + ) + + workers = [threading.Thread(target=emit, args=(i,)) for i in range(threads)] + for worker in workers: + worker.start() + for worker in workers: + worker.join() + writer.flush_now() + + goals = [e["goal"] for e in read_all(spool)] + assert len(goals) == threads * per_thread, "events were lost" + assert len(set(goals)) == len(goals), "events were duplicated" + + +def test_concurrent_flushes_publish_every_batch(spool): + """`flush_now()` from several threads at once must not drop a batch.""" + writer = EventWriter(flush_interval=3600) + total = 400 + for i in range(total): + writer.submit({"id": i}) + + barrier = threading.Barrier(8) + + def flush(): + barrier.wait() + writer.flush_now() + + workers = [threading.Thread(target=flush) for _ in range(8)] + for worker in workers: + worker.start() + for worker in workers: + worker.join() + + assert sorted(e["id"] for e in read_all(spool)) == list(range(total)) + + +def test_a_reader_never_observes_a_partially_written_batch(spool): + """The `.tmp` -> `.jsonl` rename is what makes a visible file a complete one. + + A daemon polls this directory. If it can ever open a `.jsonl` mid-write it + reads truncated JSON, ingest counts the bad lines as `skipped`, and returns + 200 — silent partial loss with a successful-looking upload. + """ + writer = EventWriter(flush_interval=3600) + events_dir = spool / "events" + events_dir.mkdir(parents=True, exist_ok=True) + stop = threading.Event() + torn: list[str] = [] + + def poll(): + while not stop.is_set(): + for path in list(events_dir.glob("*.jsonl")): + try: + text = path.read_text(encoding="utf-8") + except (FileNotFoundError, PermissionError): + continue + if text and not text.endswith("\n"): + torn.append(f"{path.name}: no trailing newline") + continue + for line in text.splitlines(): + try: + json.loads(line) + except json.JSONDecodeError: + torn.append(f"{path.name}: {line[:60]!r}") + + reader = threading.Thread(target=poll, daemon=True) + reader.start() + try: + # Batches big enough that a non-atomic write would be caught mid-flight. + for batch in range(60): + writer._write_batch([{"id": f"{batch}-{i}", "pad": "x" * 512} for i in range(200)]) + finally: + stop.set() + reader.join(timeout=5) + + assert not torn, f"reader saw incomplete batches: {torn[:5]}" + + +# ───────────────────────────────────────────────────────────────────────────── +# Failure and retry +# ───────────────────────────────────────────────────────────────────────────── + + +def test_a_failed_write_requeues_the_batch_and_the_next_flush_recovers_it(spool, monkeypatch): + """A transient filesystem error must delay events, never discard them.""" + writer = EventWriter(flush_interval=3600) + for i in range(50): + writer.submit({"id": i}) + + real_replace = os.replace + monkeypatch.setattr(os, "replace", lambda *a, **k: (_ for _ in ()).throw(OSError(28, "No space left on device"))) + + with pytest.raises(OSError): + writer.flush_now() + assert read_all(spool) == [], "a failed write must publish nothing" + assert len(writer._queue) == 50, "the batch was dropped instead of requeued" + + monkeypatch.setattr(os, "replace", real_replace) + writer.flush_now() + assert sorted(e["id"] for e in read_all(spool)) == list(range(50)) + + +def test_a_requeued_batch_keeps_its_original_order(spool, monkeypatch): + """Requeueing at the front must not reverse the batch.""" + writer = EventWriter(flush_interval=3600) + for i in range(20): + writer.submit({"id": i}) + + monkeypatch.setattr(os, "replace", lambda *a, **k: (_ for _ in ()).throw(OSError("boom"))) + with pytest.raises(OSError): + writer.flush_now() + monkeypatch.undo() + + writer.flush_now() + assert [e["id"] for e in read_all(spool)] == list(range(20)) + + +def test_the_flush_thread_survives_a_write_failure_and_retries(spool, monkeypatch): + """One bad flush must not permanently kill the background writer. + + If the loop thread dies, every subsequent event in the process is buffered + forever and lost at exit — from the caller's side, indistinguishable from + working. + """ + failures = {"n": 0} + real_replace = os.replace + + def flaky(src, dst): + if failures["n"] < 2: + failures["n"] += 1 + raise OSError("transient") + return real_replace(src, dst) + + monkeypatch.setattr(os, "replace", flaky) + writer = EventWriter(flush_interval=0.01) + writer.submit({"id": "eventually"}) + + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and not read_all(spool): + time.sleep(0.02) + + assert failures["n"] == 2, "the failure injection never fired" + assert [e["id"] for e in read_all(spool)] == ["eventually"] + assert writer._thread.is_alive() + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX permission semantics") +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="root ignores mode bits") +def test_an_unwritable_spool_retains_events_rather_than_dropping_them(spool): + """A wrong-permissions spool is a config error, not a reason to lose data.""" + writer = EventWriter(flush_interval=3600) + events_dir = spool / "events" + events_dir.mkdir(parents=True) + events_dir.chmod(0o500) # r-x: cannot create files + try: + writer.submit({"id": "kept"}) + with pytest.raises(OSError): + writer.flush_now() + assert len(writer._queue) == 1 + finally: + events_dir.chmod(0o700) + + writer.flush_now() + assert [e["id"] for e in read_all(spool)] == ["kept"] + + +# ───────────────────────────────────────────────────────────────────────────── +# Process lifecycle +# ───────────────────────────────────────────────────────────────────────────── + + +def _run_script(body: str, spool_dir: Path) -> subprocess.CompletedProcess: + script = f""" +import failproofai_sdk +failproofai_sdk.configure(base_dir={str(spool_dir)!r}, flush_interval=3600) +{body} +""" + return subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + timeout=60, + cwd=str(Path(__file__).resolve().parents[1]), + ) + + +def test_events_are_flushed_at_normal_interpreter_exit(tmp_path): + """`flush_interval=3600` means atexit is the only thing that can publish.""" + result = _run_script( + "failproofai_sdk.event.agent_start(session_id='s', agent_id='a', goal='at-exit')", + tmp_path, + ) + assert result.returncode == 0, result.stderr + assert [e["goal"] for e in read_all(tmp_path)] == ["at-exit"] + + +def test_events_are_flushed_when_the_process_exits_via_sys_exit(tmp_path): + result = _run_script( + "import sys\n" + "failproofai_sdk.event.agent_start(session_id='s', agent_id='a', goal='sys-exit')\n" + "sys.exit(3)", + tmp_path, + ) + assert result.returncode == 3 + assert [e["goal"] for e in read_all(tmp_path)] == ["sys-exit"] + + +def test_events_are_flushed_when_the_process_dies_of_an_uncaught_exception(tmp_path): + """The run that crashed is the run whose telemetry matters most.""" + result = _run_script( + "failproofai_sdk.event.error(session_id='s', agent_id='a', " + "error_type='RuntimeError', message='boom')\n" + "raise RuntimeError('boom')", + tmp_path, + ) + assert result.returncode == 1 + assert [e["type"] for e in read_all(tmp_path)] == ["error"] + + +def test_os_exit_skips_the_flush_and_that_is_documented_not_fixed(tmp_path): + """`os._exit` bypasses atexit by definition. Pinned so nobody assumes otherwise. + + There is no way to make this safe from inside the SDK — the point of + `os._exit` is to skip cleanup. The honest answer is a documented loss window + and a `flush_now()` for callers who use it, not a fix that cannot exist. + """ + result = _run_script( + "import os\n" + "failproofai_sdk.event.agent_start(session_id='s', agent_id='a', goal='lost')\n" + "os._exit(0)", + tmp_path, + ) + assert result.returncode == 0 + assert read_all(tmp_path) == [] + + +def test_flush_now_makes_os_exit_safe(tmp_path): + """The documented escape hatch for the case above.""" + result = _run_script( + "import os\n" + "failproofai_sdk.event.agent_start(session_id='s', agent_id='a', goal='kept')\n" + "failproofai_sdk._writer.flush_now()\n" + "os._exit(0)", + tmp_path, + ) + assert result.returncode == 0 + assert [e["goal"] for e in read_all(tmp_path)] == ["kept"] + + +@pytest.mark.skipif(not hasattr(os, "fork"), reason="requires fork") +# The DeprecationWarning about fork() in a multi-threaded process is the +# hazard under test, not a problem with the test. +@pytest.mark.filterwarnings("ignore:.*fork.*:DeprecationWarning") +def test_a_forked_child_can_still_emit_and_publish(spool): + """The flush thread does not survive fork; the child must not hang or lose. + + A child that inherits a queue with no thread to drain it, and then blocks + forever at exit, turns telemetry into a liveness bug in the host process. + """ + writer = EventWriter(flush_interval=3600) + namespace = EventNamespace(writer) + namespace.agent_start(session_id="parent", agent_id="a", goal="before-fork") + + pid = os.fork() + if pid == 0: # child + try: + child_writer = EventWriter(flush_interval=3600) + EventNamespace(child_writer).agent_start( + session_id="child", agent_id="a", goal="in-child" + ) + child_writer.flush_now() + os._exit(0) + except BaseException: + os._exit(70) + + _, status = os.waitpid(pid, 0) + assert os.WIFEXITED(status), "child did not exit cleanly" + assert os.WEXITSTATUS(status) == 0, f"child exited {os.WEXITSTATUS(status)}" + + writer.flush_now() + goals = {e["goal"] for e in read_all(spool)} + assert goals == {"before-fork", "in-child"} + + +# ───────────────────────────────────────────────────────────────────────────── +# Correlation state +# ───────────────────────────────────────────────────────────────────────────── + + +def test_pending_map_is_capped_and_evicts_oldest_first(): + """An agent that never closes its tool calls must not exhaust memory.""" + namespace = EventNamespace(_NullWriter()) + for i in range(_PENDING_CAP + 100): + namespace.tool_use(session_id="s", agent_id="a", tool_name="t", tool_call_id=f"c{i}") + + assert len(namespace._pending) == _PENDING_CAP + assert _tool_key("s", "c0") not in namespace._pending, "eviction is not FIFO" + assert _tool_key("s", f"c{_PENDING_CAP + 99}") in namespace._pending + + +def test_an_evicted_pair_completes_without_duration_instead_of_raising(): + """Losing a duration is acceptable. Raising inside the caller's agent is not.""" + writer = _NullWriter() + namespace = EventNamespace(writer) + namespace.tool_use(session_id="s", agent_id="a", tool_name="t", tool_call_id="evicted") + for i in range(_PENDING_CAP): + namespace.tool_use(session_id="s", agent_id="a", tool_name="t", tool_call_id=f"c{i}") + + writer.entries.clear() + namespace.tool_result(session_id="s", agent_id="a", tool_name="t", tool_call_id="evicted") + + assert "duration_ms" not in writer.entries[0] + + +def test_a_tool_and_a_hook_sharing_an_id_do_not_cross_correlate(): + """Regression: they used to share one flat keyspace in `_pending`. + + An id collision between a tool call and a hook is not exotic — both are + routinely the harness's own step id. When the keys were bare, the + `hook_completed` consumed the `tool_use` timestamp and reported the interval + between two unrelated events, and the real `tool_result` that followed got no + duration at all. Two plausible numbers, no error, nothing downstream able to + tell. + """ + writer = _NullWriter() + namespace = EventNamespace(writer) + + namespace.tool_use(session_id="s", agent_id="a", tool_name="t", tool_call_id="shared") + + # The hook never started, so its completion must not borrow the tool's start. + writer.entries.clear() + namespace.hook_completed(session_id="s", agent_id="a", hook_name="h", hook_id="shared") + assert "duration_ms" not in writer.entries[0], ( + "hook_completed consumed the tool_use timestamp — the keyspaces are flat again" + ) + + # And the tool's own pairing is untouched, so its result still gets a duration. + writer.entries.clear() + namespace.tool_result(session_id="s", agent_id="a", tool_name="t", tool_call_id="shared") + assert "duration_ms" in writer.entries[0], ( + "the tool's pending entry was consumed by the unrelated hook" + ) + + +def test_every_pairing_is_namespaced_by_what_it_pairs(): + """The keyspaces are separate in both directions, for all four pair types.""" + writer = _NullWriter() + namespace = EventNamespace(writer) + ids = dict(session_id="s", agent_id="a") + + namespace.tool_use(**ids, tool_name="t", tool_call_id="x") + namespace.hook_triggered(**ids, hook_name="h", hook_id="x") + namespace.human_wait(**ids, input_id="x") + namespace.agent_pause(**ids, pause_id="x") + + # Four starts, one shared id, four distinct pending keys. + assert len(namespace._pending) == 4, sorted(namespace._pending) + + # Each end event finds its own start and no other. + for call in ( + lambda: namespace.tool_result(**ids, tool_name="t", tool_call_id="x"), + lambda: namespace.hook_completed(**ids, hook_name="h", hook_id="x"), + lambda: namespace.human_input(**ids, input_id="x"), + lambda: namespace.agent_resume(**ids, pause_id="x"), + ): + writer.entries.clear() + call() + assert "duration_ms" in writer.entries[0], writer.entries[0]["type"] + + assert namespace._pending == {}, "an end event left its start behind" + + +def test_human_and_pause_pairs_are_namespaced_by_session_and_agent(): + """The same input id in two sessions must not cross-correlate.""" + writer = _NullWriter() + namespace = EventNamespace(writer) + + namespace.human_wait(session_id="s1", agent_id="a", input_id="same") + writer.entries.clear() + namespace.human_input(session_id="s2", agent_id="a", input_id="same") + assert "duration_ms" not in writer.entries[0] + + writer.entries.clear() + namespace.human_input(session_id="s1", agent_id="a", input_id="same") + assert "duration_ms" in writer.entries[0] + + +class _NullWriter: + def __init__(self): + self.entries = [] + + def submit(self, entry): + self.entries.append(entry) + + +# ───────────────────────────────────────────────────────────────────────────── +# Configuration +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "bad", [-1, -0.001, 0, 0.0, float("nan"), float("inf"), float("-inf")] +) +def test_an_unusable_flush_interval_is_rejected_at_the_boundary(bad): + """`time.sleep` runs OUTSIDE the loop's try, so a bad value kills the thread. + + And a dead writer thread is the worst state this class has: `submit()` keeps + accepting events, the queue keeps growing, nothing is ever written, and the + caller learns none of it. Negative, NaN and infinite intervals all raise from + `sleep`; zero does not raise but busy-loops, pinning a core and rewriting the + spool as fast as the disk allows. All of them are refused up front instead. + """ + with pytest.raises(ValueError, match="finite number greater than zero"): + EventWriter(flush_interval=bad) + + +@pytest.mark.parametrize("bad", [-1, 0, float("nan"), float("inf")]) +def test_set_flush_interval_rejects_without_changing_the_live_interval(bad): + """A refused value must leave the writer on the interval it already had.""" + writer = EventWriter(flush_interval=3600) + with pytest.raises(ValueError): + writer.set_flush_interval(bad) + assert writer._flush_interval == 3600 + assert writer._thread.is_alive() + + +@pytest.mark.parametrize("bad", [-1, 0, float("nan"), float("inf")]) +def test_configure_rejects_a_bad_interval_before_applying_anything(bad, spool, tmp_path): + """Validation comes first, so a rejected call is not a half-applied one.""" + import failproofai_sdk + + failproofai_sdk.configure(base_dir=spool, flush_interval=3600) + + with pytest.raises(ValueError, match="finite number greater than zero"): + failproofai_sdk.configure(base_dir=tmp_path / "elsewhere", flush_interval=bad) + + # base_dir is set BEFORE the interval in configure(), so validating inside + # set_flush_interval alone would have left this pointing at "elsewhere". + assert _resolver.get_base_dir() == spool + assert failproofai_sdk._writer._flush_interval == 3600 + + +def test_a_valid_interval_still_applies(): + writer = EventWriter(flush_interval=3600) + writer.set_flush_interval(0.25) + assert writer._flush_interval == 0.25 + # Ints are accepted and normalised, so `_flush_loop` always sleeps on a float. + writer.set_flush_interval(2) + assert writer._flush_interval == 2.0 + assert isinstance(writer._flush_interval, float) + + +def test_configure_can_be_called_after_events_have_already_been_emitted(spool, tmp_path): + """Late configuration must redirect the spool, not strand what is buffered.""" + import failproofai_sdk + + failproofai_sdk.configure(base_dir=spool, flush_interval=3600) + failproofai_sdk.event.agent_start(session_id="s", agent_id="a", goal="before") + + later = tmp_path / "later" + failproofai_sdk.configure(base_dir=later, flush_interval=3600) + failproofai_sdk.event.agent_start(session_id="s", agent_id="a", goal="after") + failproofai_sdk._writer.flush_now() + + # Both were still in the queue, so both land under the newest base dir. + assert {e["goal"] for e in read_all(later)} == {"before", "after"} + assert read_all(spool) == [] + + +def test_configure_is_safe_to_call_from_several_threads(spool, tmp_path): + """Racing configure() calls must not corrupt state or lose queued events.""" + import failproofai_sdk + + # This is the one test that asserts an EXACT count on the process-wide + # singleton, so it must not inherit anything another test left queued. Drain + # to a throwaway directory first — otherwise the assertion below depends on + # test execution order, and an order-dependent test fails for a reason that + # has nothing to do with what it checks. + failproofai_sdk.configure(base_dir=tmp_path / "drain", flush_interval=3600) + failproofai_sdk._writer.flush_now() + + failproofai_sdk.configure(base_dir=spool, flush_interval=3600) + barrier = threading.Barrier(8) + errors: list[BaseException] = [] + + def churn(n: int): + try: + barrier.wait() + for _ in range(50): + failproofai_sdk.configure(base_dir=spool, flush_interval=3600) + failproofai_sdk.event.agent_start(session_id=f"s{n}", agent_id="a") + except BaseException as exc: # pragma: no cover - failure path + errors.append(exc) + + workers = [threading.Thread(target=churn, args=(i,)) for i in range(8)] + for worker in workers: + worker.start() + for worker in workers: + worker.join() + failproofai_sdk._writer.flush_now() + + assert not errors, errors + assert len(read_all(spool)) == 8 * 50 + + +# ───────────────────────────────────────────────────────────────────────────── +# fork() — the flush thread does not survive it +# ───────────────────────────────────────────────────────────────────────────── + + +def test_a_forked_child_publishes_through_its_own_restarted_thread(tmp_path): + """The realistic shape: the child reuses the INHERITED singleton. + + The sibling test above builds a fresh `EventWriter` in the child and calls + `flush_now()` by hand, which proves the child does not hang and nothing else. + Nobody writes an agent that way. They `import failproofai_sdk` once, and + whatever forks — gunicorn, celery, `multiprocessing` on Linux — inherits that + module-level writer. + + Threads do not cross `fork()`, so before `os.register_at_fork` the child got + a queue and no drainer: `submit()` kept accepting, nothing was ever + published, and the events appeared only if the child happened to exit through + a normal interpreter shutdown. A prefork worker is killed instead, so all of + the telemetry — the workers are where the work happens — silently vanished. + + The child here ends with `os._exit`, which skips atexit by definition. If the + event still lands, a background thread wrote it, which is the whole claim. + """ + result = _run_script( + "import os, time\n" + "pid = os.fork()\n" + "if pid == 0:\n" + " failproofai_sdk.configure(base_dir=%r, flush_interval=0.05)\n" + " failproofai_sdk.event.agent_start(session_id='child', agent_id='a', goal='in-child')\n" + " time.sleep(1.5)\n" + " os._exit(0)\n" + "os.waitpid(pid, 0)\n" % str(tmp_path), + tmp_path, + ) + assert result.returncode == 0, result.stderr + goals = [e["goal"] for e in read_all(tmp_path)] + assert goals == ["in-child"], ( + f"the child's flush thread never restarted (got {goals!r}); " + "os._exit skips atexit, so only a live thread could have written this" + ) + + +def test_a_fork_does_not_duplicate_the_events_the_parent_had_queued(tmp_path): + """The child inherits the parent's undrained queue; only one of them owns it. + + Publishing from both produced a byte-identical duplicate of every event + buffered at the instant of the fork. Ingest would most likely collapse those + — its dedup key hashes the canonical payload — but relying on the server to + tidy up after the SDK is not a property worth shipping. + """ + result = _run_script( + "import os\n" + "failproofai_sdk.event.agent_start(session_id='p', agent_id='a', goal='queued-before-fork')\n" + "pid = os.fork()\n" + "if pid == 0:\n" + " failproofai_sdk.event.agent_start(session_id='c', agent_id='a', goal='child-only')\n" + " failproofai_sdk._writer.flush_now()\n" + " os._exit(0)\n" + "os.waitpid(pid, 0)\n" + "failproofai_sdk._writer.flush_now()\n", + tmp_path, + ) + assert result.returncode == 0, result.stderr + goals = sorted(e["goal"] for e in read_all(tmp_path)) + assert goals == ["child-only", "queued-before-fork"], ( + f"expected each event exactly once, got {goals!r}" + ) + + +def test_the_fork_handler_prunes_writers_that_have_been_collected(): + """`_live_writers` holds weak references, and drops the dead ones. + + Note what this does NOT claim. A writer is not collectable while it exists: + its flush thread targets `self._flush_loop`, and a running thread holds its + target, so in practice every writer outlives every collection. The weakness + matters because a dead referent must be SKIPPED rather than restarted, and + because it keeps this list from being a second, independent reason a writer + can never be freed — which is what `atexit.register(self._flush)` was. + + So the dead entry is injected rather than produced, because producing one + means defeating the thread that keeps it alive. + """ + import gc + import sys + import weakref + + writer_module = sys.modules["failproofai_sdk._writer"] + + class _Collectable: + def _reinit_after_fork(self): # pragma: no cover - must never be reached + raise AssertionError("a collected writer was restarted after fork") + + victim = _Collectable() + dead = weakref.ref(victim) + writer_module._live_writers.append(dead) + del victim + gc.collect() + assert dead() is None, "the test's own victim outlived it" + + writer_module._reinit_all_after_fork() + assert dead not in writer_module._live_writers, "a dead weakref was left registered" + + +# ───────────────────────────────────────────────────────────────────────────── +# The queue is bounded +# ───────────────────────────────────────────────────────────────────────────── + + +def test_the_queue_is_capped_and_discards_oldest_first(spool, caplog): + """`submit` cannot block or raise, so the only other option is to bound it. + + Unbounded, any condition that stops the spool draining turns a telemetry + outage into an OOM kill of the host agent — the SDK taking down the very + process it exists to observe. + """ + writer = EventWriter(flush_interval=3600) + with caplog.at_level(logging.WARNING, logger="failproofai_sdk._writer"): + for i in range(_QUEUE_CAP + 250): + writer.submit({"type": "e", "n": i}) + + assert len(writer._queue) == _QUEUE_CAP, "the queue is unbounded" + ns = [e["n"] for e in writer._queue] + assert ns[0] == 250, "eviction is not oldest-first" + assert ns[-1] == _QUEUE_CAP + 249, "the newest event was dropped instead of the oldest" + assert any("queue is full" in r.getMessage() for r in caplog.records), ( + "the cap discarded events without saying so" + ) + + +def test_the_full_queue_warning_does_not_fire_on_every_single_drop(spool, caplog): + """A stuck spool must not become the thing that fills the disk.""" + writer = EventWriter(flush_interval=3600) + with caplog.at_level(logging.WARNING, logger="failproofai_sdk._writer"): + for i in range(_QUEUE_CAP + 2500): + writer.submit({"type": "e", "n": i}) + + warnings = [r for r in caplog.records if "queue is full" in r.getMessage()] + assert 1 <= len(warnings) <= 5, f"{len(warnings)} warnings for 2500 drops" + + +# ───────────────────────────────────────────────────────────────────────────── +# The flush interval, and the shutdown race that changing it exposed +# ───────────────────────────────────────────────────────────────────────────── + + +def test_a_new_flush_interval_applies_to_the_cycle_already_waiting(spool): + """Otherwise `configure()` is ignored for one full cycle of the OLD interval. + + The thread starts at import, so its first wait is always the 500 ms default — + which a caller asking for 50 ms has no way to know about, and which is long + enough for a fork or an exit to land inside it. + """ + writer = EventWriter(flush_interval=3600) + writer.submit({"type": "e", "n": 1}) + + writer.set_flush_interval(0.05) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and not list((spool / "events").glob("*.jsonl")): + time.sleep(0.02) + + assert [e["n"] for e in read_all(spool)] == [1], ( + "the writer sat on the hour-long interval it was configured away from" + ) + + +def test_a_flush_racing_interpreter_shutdown_does_not_lose_the_batch(tmp_path): + """A batch is drained from the queue BEFORE it is written. + + So a flush thread stopped part-way through — which is what happens to a + daemon thread once the interpreter starts finalising — takes those events + with it, leaving at most a stray `.tmp`. The atexit flush has to WAIT on an + in-flight batch rather than find an empty queue and return, which is why the + emptiness check lives inside `_flush_lock`. + + Waking the thread on `set_flush_interval` is what made this likely enough to + reproduce: it puts a flush and the main thread's exit path in the same + moment, every run. + """ + for attempt in range(8): + target = tmp_path / f"run-{attempt}" + result = _run_script( + "failproofai_sdk.event.error(session_id='s', agent_id='a', " + "error_type='RuntimeError', message='boom')\n" + "raise RuntimeError('boom')", + target, + ) + assert result.returncode == 1 + assert [e["type"] for e in read_all(target)] == ["error"], ( + f"attempt {attempt}: the crashing run's telemetry was lost to the shutdown race" + ) + assert list((target / "events").glob("*.tmp")) == [], ( + f"attempt {attempt}: a batch was abandoned part-written" + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Correlation state — session and agent scoping +# ───────────────────────────────────────────────────────────────────────────── + + +def test_tool_pairs_are_namespaced_by_session_and_agent(): + """Two sessions in one process must not share a tool_call_id's timestamp. + + `_pending` lives on a single process-wide `EventNamespace`, and a supervisor + running agents concurrently is the ordinary multi-agent shape — so a step id + that repeats across sessions (`step-1`, and both ids are frequently the + harness's own step counter) collided. Session B's start overwrote A's, A's + result reported B's interval, and B's result reported nothing at all. + """ + writer = _NullWriter() + namespace = EventNamespace(writer) + + namespace.tool_use(session_id="A", agent_id="a", tool_name="t", tool_call_id="step-1") + namespace.tool_use(session_id="B", agent_id="b", tool_name="t", tool_call_id="step-1") + assert len(namespace._pending) == 2, "B's start overwrote A's" + + writer.entries.clear() + namespace.tool_result(session_id="A", agent_id="a", tool_name="t", tool_call_id="step-1") + assert "duration_ms" in writer.entries[0], "A's result could not find A's own start" + + writer.entries.clear() + namespace.tool_result(session_id="B", agent_id="b", tool_name="t", tool_call_id="step-1") + assert "duration_ms" in writer.entries[0], "B's start had been consumed by A's result" + + +def test_hook_pairs_are_namespaced_by_session_and_agent(): + """Same lookup pattern, same bug, same fix.""" + writer = _NullWriter() + namespace = EventNamespace(writer) + + namespace.hook_triggered(session_id="A", agent_id="a", hook_name="h", hook_id="step-1") + namespace.hook_triggered(session_id="B", agent_id="b", hook_name="h", hook_id="step-1") + assert len(namespace._pending) == 2 + + for session, agent in (("A", "a"), ("B", "b")): + writer.entries.clear() + namespace.hook_completed(session_id=session, agent_id=agent, hook_name="h", hook_id="step-1") + assert "duration_ms" in writer.entries[0], f"{session} lost its own start" + + +def test_a_pair_opened_and_closed_under_different_agents_still_pairs(): + """The key is deliberately NOT agent-scoped, and this is why. + + This test asserted the opposite when the keys were first namespaced: that a + tool id repeated under two agents in one session produced two independent + pairs. That looked like tightening; it was over-tightening. Once a framework + runs tools inside sub-agents — LangGraph and CrewAI both do — a `tool_use` + opened under `planner` and closed under `worker` is the ORDINARY case, and an + agent-scoped key makes it miss silently, dropping `duration_ms` for exactly + the nested runs that most need it. + + The rule that survives both: key on what makes the id unique (kind, session) + and never on what can legitimately change between the two events (the agent). + """ + writer = _NullWriter() + namespace = EventNamespace(writer) + + namespace.tool_use(session_id="S", agent_id="planner", tool_name="t", tool_call_id="x") + writer.entries.clear() + namespace.tool_result(session_id="S", agent_id="worker", tool_name="t", tool_call_id="x") + + assert "duration_ms" in writer.entries[0], ( + "a tool handed from planner to worker lost its duration — the key is " + "agent-scoped again" + ) + assert namespace._pending == {}, "the pending entry was left behind" + + +def test_a_hook_opened_and_closed_under_different_agents_still_pairs(): + """Same rule, same reason, for the other adapter-driven pair type.""" + writer = _NullWriter() + namespace = EventNamespace(writer) + + namespace.hook_triggered(session_id="S", agent_id="planner", hook_name="h", hook_id="x") + writer.entries.clear() + namespace.hook_completed(session_id="S", agent_id="worker", hook_name="h", hook_id="x") + assert "duration_ms" in writer.entries[0] + + +def test_a_result_from_an_unrelated_session_gets_no_duration_at_all(): + """The failure the scoping prevents: a duration measured across sessions. + + No duration is the correct answer here. A plausible number would be worse + than an absent one, because nothing downstream can tell it is wrong. + """ + writer = _NullWriter() + namespace = EventNamespace(writer) + + namespace.tool_use(session_id="A", agent_id="a", tool_name="t", tool_call_id="shared") + writer.entries.clear() + namespace.tool_result(session_id="B", agent_id="b", tool_name="t", tool_call_id="shared") + assert "duration_ms" not in writer.entries[0], "a duration leaked across sessions" + + +# ───────────────────────────────────────────────────────────────────────────── +# Durability — atomic is not the same as committed +# ───────────────────────────────────────────────────────────────────────────── + + +def _fs_trace(monkeypatch): + """Record the order of fsync/replace calls made while writing a batch.""" + import sys + + writer_module = sys.modules["failproofai_sdk._writer"] + order = [] + real_fsync, real_replace = os.fsync, writer_module.os.replace + + def fsync_spy(fd): + try: + st = os.fstat(fd) + kind = "dir" if stat.S_ISDIR(st.st_mode) else "file" + except OSError: # pragma: no cover + kind = "?" + order.append(f"fsync:{kind}") + return real_fsync(fd) + + def replace_spy(src, dst): + order.append("replace") + return real_replace(src, dst) + + monkeypatch.setattr(writer_module.os, "fsync", fsync_spy) + monkeypatch.setattr(writer_module.os, "replace", replace_spy) + return order + + +def test_the_batch_is_fsynced_before_the_rename_and_the_dir_after(spool, monkeypatch): + """`os.replace` is atomic to READERS; it commits nothing to the platter. + + Without the content fsync, a power loss can leave a correctly-named, + zero-length `.jsonl`. The collector reads it, POSTs an empty body, gets a + 200 and then DELETES the file (`remove_file` in + `crates/fpai-collect/src/uploader.rs`) — permanent, silent loss. Without the + directory fsync the reverse survives: the bytes are on disk but the rename + is not, so the batch sits under a `.tmp` name the watcher ignores by design. + + This repo's own Rust spool writer has called `sync_all()` here from the + start (`crates/fpai-collect/src/spool.rs`); the Python writer publishing + into the same directories was the odd one out. + """ + order = _fs_trace(monkeypatch) + writer = EventWriter(flush_interval=3600) + writer.submit({"type": "e", "n": 1}) + writer.flush_now() + + assert "replace" in order, "no rename happened" + assert order.index("fsync:file") < order.index("replace"), ( + f"content was renamed before it was committed: {order}" + ) + assert any(o == "fsync:dir" for o in order[order.index("replace"):]), ( + f"the rename itself was never committed: {order}" + ) + + +def test_a_published_batch_is_complete_on_disk_not_merely_present(spool): + """The property the fsync exists to buy, asserted at the file level.""" + writer = EventWriter(flush_interval=3600) + namespace = EventNamespace(writer) + for i in range(200): + namespace.agent_start(session_id="s", agent_id="a", goal=f"g{i}") + writer.flush_now() + + published = read_all(spool) + assert len(published) == 200 + for path in (spool / "events").glob("*.jsonl"): + text = path.read_text(encoding="utf-8") + assert text.endswith("\n"), f"{path.name} is truncated mid-line" + assert all(json.loads(line) for line in text.splitlines()) + + +def test_a_crash_between_write_and_rename_leaves_no_half_batch(spool, monkeypatch): + """Fault injection: the rename never happens. + + The watcher must see nothing — a `.tmp` is not a `.jsonl` — and the events + must go back on the queue rather than being counted as delivered. + """ + import sys + + writer_module = sys.modules["failproofai_sdk._writer"] + monkeypatch.setattr( + writer_module.os, "replace", + lambda *a: (_ for _ in ()).throw(OSError(5, "simulated crash before rename")), + ) + writer = EventWriter(flush_interval=3600) + writer.submit({"type": "e", "n": 1}) + + with pytest.raises(OSError): + writer.flush_now() + + assert list((spool / "events").glob("*.jsonl")) == [], "a batch was published anyway" + assert len(writer._queue) == 1, "the events were dropped rather than retried" + + +# ───────────────────────────────────────────────────────────────────────────── +# The correlation map is mutated from the caller's own threads +# ───────────────────────────────────────────────────────────────────────────── + + +def test_evicting_at_the_cap_never_raises_into_the_callers_thread(): + """Regression: `len()` -> `next(iter())` -> `del` is a read-modify-write. + + Nothing serialised the three, so two threads arriving at a full `_pending` + picked the SAME victim and the second `del` raised KeyError — straight out + of `event.tool_use()`, in the caller's agent loop. Measured at 24 crashes + per 30_000 calls across 10 threads before the fix. + + It only fires once `_pending` is full, which is exactly the long-running + multi-agent process the cap exists for, so "rare" here means "only in + production". + """ + namespace = EventNamespace(_NullWriter()) + errors: list[BaseException] = [] + + def churn(worker: int): + try: + for i in range(3000): + namespace.tool_use( + session_id="s", agent_id="a", tool_name="t", tool_call_id=f"{worker}-{i}" + ) + if i % 7 == 0: + namespace.tool_result( + session_id="s", agent_id="a", tool_name="t", tool_call_id=f"{worker}-{i}" + ) + except BaseException as exc: # pragma: no cover - failure path + errors.append(exc) + + threads = [threading.Thread(target=churn, args=(w,)) for w in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, f"{len(errors)} exception(s) reached the caller: {errors[:3]}" + assert len(namespace._pending) <= _PENDING_CAP * 2, ( + f"the cap stopped bounding anything: {len(namespace._pending)}" + ) + + +def test_the_cap_still_bounds_the_map_after_the_concurrency_fix(): + """Tolerant eviction must not become no eviction.""" + namespace = EventNamespace(_NullWriter()) + for i in range(_PENDING_CAP + 500): + namespace.tool_use(session_id="s", agent_id="a", tool_name="t", tool_call_id=f"c{i}") + assert len(namespace._pending) == _PENDING_CAP + + +def test_a_persistent_write_fault_does_not_strand_a_tmp_file_per_cycle(spool, monkeypatch): + """Each flush picks a fresh stem, so a stuck rename leaked one file a cycle. + + At the default 500 ms interval that is ~170_000 files a day, on the very + disk that is already the problem — and the watcher ignores them by + extension, so nothing else would ever notice or collect them. + + The batch itself must survive: `_flush` returns the entries to the queue and + the next cycle rewrites them under a new name. + """ + import sys + + writer_module = sys.modules["failproofai_sdk._writer"] + real_replace = writer_module.os.replace + monkeypatch.setattr( + writer_module.os, "replace", + lambda *a: (_ for _ in ()).throw(OSError(28, "No space left on device")), + ) + + writer = EventWriter(flush_interval=3600) + for i in range(50): + writer.submit({"type": "e", "n": i}) + with pytest.raises(OSError): + writer.flush_now() + + assert list((spool / "events").glob("*.tmp")) == [], "orphaned .tmp files accumulated" + assert len(writer._queue) == 50, "events were lost to the failed writes" + + monkeypatch.setattr(writer_module.os, "replace", real_replace) + writer.flush_now() + assert len(read_all(spool)) == 50, "the recovered batch is incomplete" + assert list((spool / "events").glob("*.tmp")) == [] + + +# ───────────────────────────────────────────────────────────────────────────── +# SIGTERM — the exit path the docs got wrong +# ───────────────────────────────────────────────────────────────────────────── + + +def _run_child(tmp_path: Path, body: str) -> subprocess.CompletedProcess: + """Run `body` in a fresh interpreter spooling into `tmp_path`. + + A subprocess rather than a fork: the point is CPython's *default* signal + disposition in a process this test did not otherwise touch, and pytest's own + handlers are inherited across a fork. + """ + src = ( + "import os, signal, sys\n" + "import failproofai_sdk as fp\n" + "from failproofai_sdk import event\n" + f"fp.configure(base_dir={str(tmp_path)!r}, flush_interval=3600.0)\n" + body + ) + return subprocess.run( + [sys.executable, "-c", src], + capture_output=True, + text=True, + timeout=60, + cwd=str(Path(__file__).resolve().parents[1]), + ) + + +def test_sigterm_drops_the_queue_because_cpython_runs_no_atexit_for_it(tmp_path): + """The claim this replaces said `atexit` *does* run on `SIGTERM`. It does not. + + CPython installs no handler for `SIGTERM` — `signal.getsignal(SIGTERM)` is + `SIG_DFL` — so the OS terminates the process where it stands and the atexit + flush never runs. `SKILL.md` told readers the opposite, under a heading + naming rolling deploys and `docker stop`, which is exactly the population + that would have believed it and shipped nothing. + + The long flush interval isolates the exit path: in real use the 0.5s default + is what bounds the loss, and that bound is the whole mitigation. + """ + proc = _run_child( + tmp_path, + "with fp.session('sigterm-bare'):\n" + " for i in range(20):\n" + " event.agent_start(agent_id='a', goal=str(i))\n" + "os.kill(os.getpid(), signal.SIGTERM)\n", + ) + assert proc.returncode == -15, proc.stderr + assert read_all(tmp_path) == [], ( + "SIGTERM must be shown losing the queue; if this now passes events " + "through, the SDK grew a handler and SKILL.md's recipe is obsolete" + ) + + +def test_the_documented_sigterm_handler_saves_the_queue_and_closes_the_run(tmp_path): + """The recipe SKILL.md now ships, executed rather than described. + + `sys.exit` and not `os._exit`: it unwinds, so an open `agent()` scope emits + its `agent_end` before the flush — the events most likely to be in flight at + shutdown are exactly the ones that close a run. + """ + proc = _run_child( + tmp_path, + "def _flush_and_exit(signum, frame):\n" + " fp._writer.flush_now()\n" + " sys.exit(128 + signum)\n" + "signal.signal(signal.SIGTERM, _flush_and_exit)\n" + "with fp.agent('worker', session_id='sigterm-handled', goal='survive'):\n" + " for i in range(20):\n" + " event.tool_use(tool_name='t', tool_call_id=str(i), input={'i': i})\n" + " os.kill(os.getpid(), signal.SIGTERM)\n", + ) + assert proc.returncode == 128 + 15, proc.stderr + events = read_all(tmp_path) + kinds = {e["type"] for e in events} + assert len([e for e in events if e["type"] == "tool_use"]) == 20 + assert "agent_start" in kinds and "agent_end" in kinds + # The interrupted run closes as failed, carrying the SystemExit — an evicted + # run did not finish, and that is the thing an operator needs to see. + end = next(e for e in events if e["type"] == "agent_end") + assert end["outcome"] == "failed" diff --git a/sdk/python/tests/test_encoding.py b/sdk/python/tests/test_encoding.py new file mode 100644 index 000000000..e54aac8ba --- /dev/null +++ b/sdk/python/tests/test_encoding.py @@ -0,0 +1,518 @@ +"""One event the encoder cannot handle must not take the others with it. + +Serialisation used to be a single `json.dumps` over the whole drained batch, so +an unencodable payload was not a lost event — it was a lost SPOOL. `_flush` +returned the batch to the queue and re-raised, `_flush_loop` logged and retried +the identical batch on the next interval, and that repeated for the life of the +process. Every event emitted afterwards queued up behind the one that could not +be written, and the only outward sign was `Exception ignored in atexit callback` +on the way out, which reads as a crash in the host application. + +`default=str` is not a defence. It is consulted for unsupported *values*, so it +rescues datetime, UUID and Decimal, and does nothing at all for a non-str dict +key or a reference cycle — the two shapes an ordinary agent payload actually +arrives in. A tuple-keyed cache and an ORM row holding a back-reference are both +perfectly reasonable things to hand a telemetry call. + +Each test here is written so that it FAILS against the batch-wide encoder: they +assert on the events published beside the bad one, not on the bad one itself. +""" +import json +import logging + +import pytest + +from failproofai_sdk import _resolver +from failproofai_sdk._events import EventNamespace +from failproofai_sdk._writer import ( + _CYCLE_MARKER, + _DEPTH_MARKER, + _MAX_SANITIZE_DEPTH, + EventWriter, + _encode_entry, + _sanitize, +) + + +@pytest.fixture +def spool(tmp_path): + _resolver.set_base_dir(tmp_path) + yield tmp_path + _resolver.set_base_dir(None) + + +def read_all(spool_dir): + events = [] + for path in sorted((spool_dir / "events").glob("*.jsonl")): + for line in path.read_text(encoding="utf-8").splitlines(): + events.append(json.loads(line)) + return events + + +# A payload key that cannot be coerced even by the fallback, so `_encode_entry` +# has to give up on the event rather than find a way through. +class _Unstringable: + def __repr__(self): + raise RuntimeError("nope") + + def __str__(self): + raise RuntimeError("nope") + + def __hash__(self): + return 0 + + +# ───────────────────────────────────────────────────────────────────────────── +# The wedge itself +# ───────────────────────────────────────────────────────────────────────────── + + +def test_a_poison_payload_does_not_stop_the_events_around_it(spool): + """The regression. One bad event, six good ones, and the six must land.""" + writer = EventWriter(flush_interval=3600) + namespace = EventNamespace(writer) + + namespace.agent_start(session_id="s", agent_id="a", goal="before") + # A cache keyed by tuple — `json.dumps` refuses this no matter what + # `default=` is set to, because `default=` is never consulted for keys. + namespace.tool_use( + session_id="s", agent_id="a", tool_name="t", tool_call_id="c1", + input={"cache": {(1, 2): "hit"}}, + ) + for i in range(5): + namespace.agent_start(session_id="s", agent_id="a", goal=f"after-{i}") + + writer.flush_now() + + goals = {e.get("goal") for e in read_all(spool)} + assert "before" in goals, "the event emitted BEFORE the bad one was lost with it" + assert {f"after-{i}" for i in range(5)} <= goals, "events queued behind the bad one never published" + + +def test_the_spool_keeps_advancing_after_a_poison_payload(spool): + """Not just this batch — every batch after it. + + The old failure was permanent: the same batch was retried forever, so no + event emitted at any later point ever reached disk. + """ + writer = EventWriter(flush_interval=3600) + namespace = EventNamespace(writer) + + circular: dict = {} + circular["self"] = circular + namespace.tool_use( + session_id="s", agent_id="a", tool_name="t", tool_call_id="c1", input=circular + ) + writer.flush_now() + + namespace.agent_start(session_id="s", agent_id="a", goal="much-later") + writer.flush_now() + + assert "much-later" in {e.get("goal") for e in read_all(spool)} + + +def test_the_queue_does_not_grow_behind_a_poison_payload(spool): + """The wedge was also a leak: nothing drained, so everything accumulated.""" + writer = EventWriter(flush_interval=3600) + namespace = EventNamespace(writer) + + namespace.tool_use( + session_id="s", agent_id="a", tool_name="t", tool_call_id="c1", + input={"bad": {(1, 2): "x"}}, + ) + for i in range(20): + namespace.agent_start(session_id="s", agent_id="a", goal=f"g{i}") + writer.flush_now() + + assert len(writer._queue) == 0, "entries were returned to the queue and will be retried forever" + + +# ───────────────────────────────────────────────────────────────────────────── +# What the fallback preserves, and what it gives up on +# ───────────────────────────────────────────────────────────────────────────── + + +def test_a_non_str_key_is_coerced_rather_than_dropped(spool): + """Recovering the event beats discarding it; the payload is still readable.""" + writer = EventWriter(flush_interval=3600) + EventNamespace(writer).tool_use( + session_id="s", agent_id="a", tool_name="t", tool_call_id="c1", + input={"counts": {1: "one", (2, 3): "two-three"}}, + ) + writer.flush_now() + + published = read_all(spool) + assert len(published) == 1 + assert published[0]["input"]["counts"] == {"1": "one", "(2, 3)": "two-three"} + + +def test_a_cycle_becomes_a_marker_and_the_rest_of_the_event_survives(spool): + writer = EventWriter(flush_interval=3600) + payload: dict = {"name": "node", "size": 3} + payload["self"] = payload + EventNamespace(writer).tool_use( + session_id="s", agent_id="a", tool_name="t", tool_call_id="c1", input=payload + ) + writer.flush_now() + + published = read_all(spool)[0] + assert published["input"]["self"] == _CYCLE_MARKER + assert published["input"]["name"] == "node", "unrelated keys were collateral damage" + assert published["input"]["size"] == 3 + assert published["tool_call_id"] == "c1", "the envelope was rewritten too" + + +def test_a_shared_reference_is_not_mistaken_for_a_cycle(): + """A DAG is not a cycle. Flagging one would corrupt a perfectly good event. + + This is why `_sanitize` tracks the ids on the current PATH rather than every + id it has ever seen: the same dict appearing twice as siblings encodes fine. + """ + shared = {"shared": True} + encoded = _encode_entry({"a": shared, "b": shared, "c": [shared, shared]}) + assert encoded is not None + assert _CYCLE_MARKER not in encoded + assert json.loads(encoded)["b"] == {"shared": True} + + +def test_depth_is_bounded_so_the_fallback_cannot_blow_the_stack(): + """The fallback must not fail the way it exists to prevent.""" + deep: dict = {} + node = deep + for _ in range(_MAX_SANITIZE_DEPTH + 50): + node["next"] = {} + node = node["next"] + node["bad"] = {(1, 2): "forces the fallback"} + + encoded = _encode_entry(deep) + assert encoded is not None + assert _DEPTH_MARKER in encoded + + +def test_an_event_that_cannot_be_encoded_at_all_is_dropped_alone(spool, caplog): + """Give up on the one event. Never on the batch.""" + writer = EventWriter(flush_interval=3600) + writer.submit({"type": "good-before", "n": 1}) + writer.submit({"type": "hopeless", "payload": {_Unstringable(): "x"}}) + writer.submit({"type": "good-after", "n": 2}) + + with caplog.at_level(logging.ERROR, logger="failproofai_sdk._writer"): + writer.flush_now() + + types = [e["type"] for e in read_all(spool)] + assert types == ["good-before", "good-after"] + assert any("dropped 1 unserializable" in r.getMessage() for r in caplog.records), caplog.text + + +def test_a_batch_of_nothing_but_poison_publishes_no_file_and_does_not_raise(spool): + writer = EventWriter(flush_interval=3600) + writer.submit({"type": "hopeless", "payload": {_Unstringable(): "x"}}) + writer.flush_now() + + assert list((spool / "events").glob("*")) == [] or read_all(spool) == [] + assert len(writer._queue) == 0 + + +# ───────────────────────────────────────────────────────────────────────────── +# The two failure classes must stay distinguishable +# ───────────────────────────────────────────────────────────────────────────── + + +def test_a_filesystem_error_still_retries_the_whole_batch(spool, monkeypatch): + """Encoding failures drop; IO failures retry. Collapsing the two loses data. + + An unencodable event is permanent — retrying produces the identical failure — + while a full disk or a momentarily read-only mount is not. If the IO path + started dropping too, a transient error would silently discard live events. + """ + writer = EventWriter(flush_interval=3600) + writer.submit({"type": "keep-me", "n": 1}) + + import sys + + writer_module = sys.modules["failproofai_sdk._writer"] + real_replace = writer_module.os.replace + calls = {"n": 0} + + def flaky(src, dst): + calls["n"] += 1 + if calls["n"] == 1: + raise OSError(28, "No space left on device") + return real_replace(src, dst) + + monkeypatch.setattr(writer_module.os, "replace", flaky) + + with pytest.raises(OSError): + writer.flush_now() + assert len(writer._queue) == 1, "a transient IO error discarded the batch" + + monkeypatch.undo() + writer.flush_now() + assert [e["type"] for e in read_all(spool)] == ["keep-me"] + + +# ───────────────────────────────────────────────────────────────────────────── +# The fast path must not have moved +# ───────────────────────────────────────────────────────────────────────────── + + +def test_an_ordinary_event_takes_the_strict_path_byte_for_byte(): + """`test_wire_format.py` pins the bytes; this pins that nothing re-encodes them. + + The fallback rebuilds dicts, and a rebuild is exactly the kind of change that + reorders keys. Key order is load-bearing here — ingest's dedup key hashes the + canonical payload, so a reordered event stops retried batches collapsing and + surfaces as duplicate rows rather than as an error. + """ + entry = { + "timestamp": "2026-01-02T03:04:05.678901Z", + "session_id": "sess-1", + "agent_id": "agent-1", + "type": "tool_use", + "tool_name": "bash", + "tool_call_id": "tc-1", + "environment": "prod", + "input": {"command": "ls"}, + } + assert _encode_entry(entry) == json.dumps(entry, default=str) + + +def test_default_str_still_rescues_the_values_it_always_did(): + """The fallback is an addition, not a replacement.""" + from datetime import datetime, timezone + from decimal import Decimal + from uuid import UUID + + encoded = _encode_entry( + { + "type": "tool_result", + "when": datetime(2026, 1, 2, tzinfo=timezone.utc), + "id": UUID("00000000-0000-0000-0000-00000000beef"), + "cost": Decimal("0.25"), + } + ) + decoded = json.loads(encoded) + assert decoded["cost"] == "0.25" + assert decoded["id"].endswith("beef") + assert _CYCLE_MARKER not in encoded, "an ordinary event took the fallback path" + + +def test_sanitize_leaves_an_already_clean_payload_alone(): + payload = {"a": 1, "b": [1, 2, {"c": "d"}], "e": None, "f": True} + assert _sanitize(payload, frozenset()) == payload + + +# ───────────────────────────────────────────────────────────────────────────── +# Non-finite floats — valid Python, invalid JSON +# ───────────────────────────────────────────────────────────────────────────── + + +def _strict_loads(line): + """Parse the way a conforming JSON reader does: NaN/Infinity are not values.""" + def reject(token): + raise ValueError(f"non-standard JSON constant: {token}") + return json.loads(line, parse_constant=reject) + + +@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")]) +def test_a_non_finite_float_still_produces_standard_json(spool, bad): + """`json.dumps` writes NaN / Infinity / -Infinity by default. + + Those are a Python extension, not JSON, and — this is the part that made it + dangerous — `json.dumps` does NOT raise on them. The encoder's fallback was + never reached, so a malformed line went out looking like a clean success and + a strict NDJSON reader on the far side drops it. + """ + writer = EventWriter(flush_interval=3600) + EventNamespace(writer).tool_use( + session_id="s", agent_id="a", tool_name="t", tool_call_id="c1", + input={"score": bad, "label": "kept"}, + ) + writer.flush_now() + + (batch,) = list((spool / "events").glob("*.jsonl")) + for line in batch.read_text(encoding="utf-8").splitlines(): + _strict_loads(line) # raises if NaN/Infinity survived + + published = read_all(spool)[0] + assert published["input"]["score"] is None, "the non-finite value was not neutralised" + assert published["input"]["label"] == "kept", "the sibling field was collateral damage" + + +def test_the_raw_bytes_contain_no_nan_or_infinity_tokens(spool): + writer = EventWriter(flush_interval=3600) + EventNamespace(writer).tool_use( + session_id="s", agent_id="a", tool_name="t", tool_call_id="c1", + input={"a": float("nan"), "b": float("inf"), "c": float("-inf")}, + ) + writer.flush_now() + raw = next((spool / "events").glob("*.jsonl")).read_text(encoding="utf-8") + assert "NaN" not in raw and "Infinity" not in raw, raw + + +def test_non_finite_floats_nested_in_lists_are_neutralised_too(spool): + writer = EventWriter(flush_interval=3600) + EventNamespace(writer).tool_use( + session_id="s", agent_id="a", tool_name="t", tool_call_id="c1", + input={"series": [1.0, float("nan"), 3.0]}, + ) + writer.flush_now() + assert read_all(spool)[0]["input"]["series"] == [1.0, None, 3.0] + + +def test_finite_floats_are_left_exactly_alone(): + """The fix must not round, reformat or otherwise touch ordinary numbers.""" + entry = {"type": "e", "a": 0.1, "b": -2.5, "c": 1e300, "d": 0.0} + assert _encode_entry(entry) == json.dumps(entry, default=str, allow_nan=False) + assert json.loads(_encode_entry(entry))["a"] == 0.1 + + +def test_a_non_finite_float_does_not_push_the_event_onto_the_drop_path(spool, caplog): + """It is recoverable, so it must be recovered — not logged and discarded.""" + writer = EventWriter(flush_interval=3600) + writer.submit({"type": "recoverable", "x": float("nan")}) + with caplog.at_level(logging.ERROR, logger="failproofai_sdk._writer"): + writer.flush_now() + assert [e["type"] for e in read_all(spool)] == ["recoverable"] + assert not any("dropped" in r.getMessage() for r in caplog.records), caplog.text + + +# ───────────────────────────────────────────────────────────────────────────── +# The encoder runs the CALLER'S code, which can raise anything +# ───────────────────────────────────────────────────────────────────────────── + + +class _ReprRaises: + """`default=str` calls this, and it does not raise TypeError or ValueError.""" + + def __init__(self, exc): + self._exc = exc + + def __repr__(self): + raise self._exc + + +@pytest.mark.parametrize( + "exc", + [RuntimeError("boom"), OSError(5, "io in __repr__"), KeyError("k"), AttributeError("a")], + ids=lambda e: type(e).__name__, +) +def test_an_exploding_repr_drops_one_event_and_never_wedges_the_batch(spool, exc): + """Regression: the narrow `except (TypeError, ValueError, RecursionError)`. + + `default=str` runs the caller's `__repr__`, which can raise anything — a + RuntimeError from a lazy ORM attribute, an OSError from a property that + touches the network. Anything outside those three escaped `_encode_entry`, + propagated out of `_write_batch`, and put the whole batch back on the queue + to be retried identically forever. That is the same permanent wedge this + module exists to prevent, reached through a different exception type. + """ + writer = EventWriter(flush_interval=3600) + writer.submit({"type": "before", "n": 1}) + writer.submit({"type": "poison", "v": _ReprRaises(exc)}) + writer.submit({"type": "after", "n": 2}) + + writer.flush_now() # must not raise + + assert [e["type"] for e in read_all(spool)] == ["before", "after"] + assert len(writer._queue) == 0, "the batch was re-queued and will retry forever" + + +def test_a_keyboard_interrupt_during_encoding_is_not_swallowed(spool): + """`except Exception`, never `BaseException`. + + Telemetry must not be able to eat a Ctrl-C — an SDK that makes a process + unkillable during a flush is worse than one that loses an event. + """ + writer = EventWriter(flush_interval=3600) + writer.submit({"type": "e", "v": _ReprRaises(KeyboardInterrupt())}) + with pytest.raises(KeyboardInterrupt): + writer.flush_now() + + +@pytest.mark.parametrize( + "name,payload", + [ + ("lone surrogate", {"s": "bad \ud800 pair"}), + ("unpaired low surrogate", {"s": "\udfff"}), + ("null byte", {"s": "a\x00b"}), + ("int beyond any integer type", {"n": 10**400}), + ("bytes", {"b": b"\xff\xfe"}), + ("set", {"v": {1, 2, 3}}), + ("300-deep nesting", None), + ("200KB string", {"s": "x" * 200_000}), + ], +) +def test_hostile_but_legitimate_payloads_still_publish(spool, name, payload): + """None of these are mistakes; agents really hand these to a telemetry call. + + A lone surrogate comes back from a truncated model response, a null byte + from a binary tool output, a 10**400 from a numeric library. + """ + if payload is None: # built here so the parametrize id stays readable + payload = cur = {} + for _ in range(300): + cur["n"] = {} + cur = cur["n"] + + writer = EventWriter(flush_interval=3600) + EventNamespace(writer).tool_use( + session_id="s", agent_id="a", tool_name="t", tool_call_id="c1", input=payload + ) + writer.flush_now() + + published = read_all(spool) + assert len(published) == 1, f"{name} was dropped" + assert published[0]["tool_call_id"] == "c1" + + +# ───────────────────────────────────────────────────────────────────────────── +# Lone surrogates — encode cleanly here, get skipped by ingest +# ───────────────────────────────────────────────────────────────────────────── + + +def test_a_lone_surrogate_is_scrubbed_so_ingest_accepts_the_event(spool): + """The whole event was silently skipped, and nothing failed locally. + + `os.fsdecode` and `bytes.decode(errors="surrogateescape")` — how Python + carries bytes that are not valid UTF-8, and what a filesystem path or a + truncated tool output arrives as — produce lone surrogates. `json.dumps` + escapes them to the literal text \\udcff without complaint, so the SDK saw a + clean success. The server then skipped the row: verified live, before the + fix `{"accepted":0,"skipped":1}` at 200 OK, after it `{"accepted":1}`. + """ + writer = EventWriter(flush_interval=3600) + EventNamespace(writer).tool_use( + session_id="s", agent_id="a", tool_name="t", tool_call_id="c1", + input={"path": "file-\udcff-name", "ok": "kept"}, + ) + writer.flush_now() + + (batch,) = list((spool / "events").glob("*.jsonl")) + decoded = read_all(spool)[0] + assert decoded["input"]["ok"] == "kept", "the sibling field was lost" + assert "\udcff" not in json.dumps(decoded), "a lone surrogate survived into the payload" + assert "udcff" in decoded["input"]["path"], "the offending byte was silently discarded" + + +def test_the_surrogate_scan_does_not_touch_ordinary_events(): + """One substring scan per line; a clean event must take the fast path.""" + entry = {"type": "tool_use", "input": {"cmd": "ls -la", "emoji": "🚀", "n": 1}} + assert _encode_entry(entry) == json.dumps(entry, default=str, allow_nan=False) + + +def test_a_payload_whose_text_merely_looks_like_an_escape_still_round_trips(): + """A false positive on the scan must cost correctness nothing.""" + entry = {"type": "e", "s": r"literally \ud800 as text"} + decoded = json.loads(_encode_entry(entry)) + assert decoded["s"] == r"literally \ud800 as text" + + +def test_surrogates_nested_anywhere_are_reached(spool): + writer = EventWriter(flush_interval=3600) + EventNamespace(writer).tool_use( + session_id="s", agent_id="a", tool_name="t", tool_call_id="c1", + input={"deep": {"list": ["ok", "bad-\udcff"]}}, + ) + writer.flush_now() + assert "\udcff" not in json.dumps(read_all(spool)[0]) diff --git a/sdk/python/tests/test_integrations.py b/sdk/python/tests/test_integrations.py new file mode 100644 index 000000000..5aac5f349 --- /dev/null +++ b/sdk/python/tests/test_integrations.py @@ -0,0 +1,998 @@ +"""The integrations scaffolding, exercised without any framework installed. + +Everything here runs against a **fake** adapter registered into the real +registry, and `sys.modules` stubs for the patch mechanics. That is deliberate: +the registry, the failure policy and the install/uninstall discipline are the +parts that must be reviewable on their own, before any framework-specific code +exists to hide behind. + +Two properties are only testable because `FAILPROOFAI_SDK_STRICT` exists. Without it +you can prove "the customer's call still worked", but never "we swallowed the +right thing" — and an adapter that swallows `BaseException` passes the first +test while silently breaking cancellation in every async app that installs it. +""" + +import dataclasses +import collections +import logging +import sys +import threading +import types +import warnings + +import pytest + +import failproofai_sdk +from failproofai_sdk import integrations +from failproofai_sdk.integrations import _compat, _core + +INTEGRATIONS_LOGGER = "failproofai_sdk.integrations" + + +@pytest.fixture(autouse=True) +def _integration_state(monkeypatch): + """Both strict flags off, no degraded sites, no already-shown warnings. + + All of this is process-global by design (a degraded hook must stay degraded + for the life of the process), so it has to be reset around every test or the + order of the file changes its result. + """ + monkeypatch.delenv("FAILPROOFAI_SDK_STRICT", raising=False) + monkeypatch.delenv("FAILPROOFAI_SDK_STRICT_INTEGRATIONS", raising=False) + _reset_flags() + yield + _reset_flags() + + +def _reset_flags(): + _core.set_strict(None) + _compat.set_strict_integrations(None) + _core.reset_failures() + _compat.reset_warnings() + + +def strict_on(monkeypatch, var="FAILPROOFAI_SDK_STRICT"): + monkeypatch.setenv(var, "1") + _core.set_strict(None) + _compat.set_strict_integrations(None) + + +class FakeAdapter: + """The shape `failproofai_sdk/integrations/<framework>.py` has to implement.""" + + def __init__(self, name="fake", module="fakeframework", fail_install=False): + self.name = name + self.module = module + self.fail_install = fail_install + self.installs = 0 + self.uninstalls = 0 + self.options = None + + def install(self, **options): + self.installs += 1 + self.options = options + if self.fail_install: + raise RuntimeError("adapter install exploded") + + def uninstall(self): + self.uninstalls += 1 + + +def register(monkeypatch, adapter, *, name=None, detect=None): + """Put an adapter into the real registry for the duration of one test.""" + name = name or adapter.name + module_path = f"agenteye_fake_{name}" + module = types.ModuleType(module_path) + module.adapter = adapter + monkeypatch.setitem(sys.modules, module_path, module) + monkeypatch.setitem(integrations._REGISTRY, name, module_path) + monkeypatch.setitem(integrations._DETECT, name, detect or (adapter.module,)) + return adapter + + +@pytest.fixture() +def fake(monkeypatch): + adapter = register(monkeypatch, FakeAdapter()) + yield adapter + # Before monkeypatch unwinds the registry, so _ACTIVE cannot keep a + # reference to a test-scoped adapter. + integrations.uninstrument() + + +# --------------------------------------------------------------------------- +# registry +# --------------------------------------------------------------------------- + +def test_the_four_adapters_are_pre_registered(): + # Registered before their modules exist so that adding an adapter is one new + # file, never an edit to the registry (which four agents would conflict on). + assert integrations.available() == ("crewai", "langchain", "llama_index", "pydantic_ai") + + +@pytest.mark.parametrize( + "spelling,expected", + [ + ("langgraph", "langchain"), + ("LangGraph", "langchain"), + ("llamaindex", "llama_index"), + ("llama-index", "llama_index"), + ("pydantic-ai", "pydantic_ai"), + (" crewai ", "crewai"), + ], +) +def test_aliases_resolve(spelling, expected): + assert integrations._canonical(spelling) == expected + + +def test_unknown_name_raises_valueerror_listing_the_valid_ones(): + # A typo that silently records nothing is the worst outcome available. + with pytest.raises(ValueError) as excinfo: + integrations.instrument("langhcain") + message = str(excinfo.value) + assert "langhcain" in message + for name in ("langchain", "crewai", "llama_index", "pydantic_ai"): + assert name in message + + +def test_instrument_installs_and_reports_the_name(fake): + assert integrations.instrument("fake") == ("fake",) + assert fake.installs == 1 + assert integrations.active() == ("fake",) + + +def test_the_public_facade_reaches_the_registry(fake): + # `failproofai_sdk.instrument` imports this package inside the function body, so + # this is also the test that the lazy wiring is connected at all. + assert failproofai_sdk.instrument("fake") == ("fake",) + assert failproofai_sdk.uninstrument("fake") == ("fake",) + with pytest.raises(ValueError): + failproofai_sdk.instrument("bogus") + + +def test_options_reach_the_adapter(fake): + integrations.instrument("fake", session_id="s-1", capture_inputs=False) + assert fake.options == {"session_id": "s-1", "capture_inputs": False} + + +def test_instrumenting_twice_is_a_noop(fake): + assert integrations.instrument("fake") == ("fake",) + assert integrations.instrument("fake") == () + assert fake.installs == 1 + + +def test_uninstrument_calls_uninstall_once(fake): + integrations.instrument("fake") + assert integrations.uninstrument("fake") == ("fake",) + assert fake.uninstalls == 1 + assert integrations.active() == () + + +def test_uninstrument_of_something_never_instrumented_is_a_noop(fake): + assert integrations.uninstrument("fake") == () + assert fake.uninstalls == 0 + + +def test_uninstrument_of_an_unknown_name_does_not_raise(caplog): + # Teardown that can fail is teardown people stop calling. + with caplog.at_level(logging.WARNING, logger=INTEGRATIONS_LOGGER): + assert integrations.uninstrument("nope") == () + assert "nope" in caplog.text + + +def test_bare_instrument_with_nothing_imported_warns_rather_than_going_quiet( + monkeypatch, caplog +): + # The import-order mistake — calling instrument() above the `import + # langchain` line — instruments nothing and is otherwise indistinguishable + # from working: no exception, no events, adapter "installed". The message + # naming the fix existed at debug level, where no default logging config + # shows it, so the one mistake that costs a user all of their telemetry was + # the one mistake the SDK said nothing about. + # + # Detection is pointed at modules that cannot be imported, rather than the + # registry being emptied: tests/integrations/ runs first and imports all + # four frameworks, so without this `instrument()` would install them for + # real and leak `_ACTIVE` into every test after this one — but an empty + # registry would also render the message's list of valid names as nothing, + # which is the half of it most worth asserting. + monkeypatch.setattr( + integrations, + "_DETECT", + {name: ("failproofai_sdk_no_such_framework",) for name in integrations._REGISTRY}, + ) + with caplog.at_level(logging.WARNING, logger=INTEGRATIONS_LOGGER): + assert integrations.instrument() == () + assert "NOTHING was instrumented" in caplog.text + assert [r for r in caplog.records if r.levelno >= logging.WARNING] + # It must name what this call WOULD have accepted. Suggesting one hardcoded + # framework leaves a reader who is not using it unsure whether the message + # is a suggestion or a diagnosis. + for name in integrations._REGISTRY: + assert f"instrument({name!r})" in caplog.text + + +def test_uninstrument_all_removes_everything(monkeypatch): + one = register(monkeypatch, FakeAdapter(name="fake1", module="fw1")) + two = register(monkeypatch, FakeAdapter(name="fake2", module="fw2")) + integrations.instrument("fake1") + integrations.instrument("fake2") + assert sorted(integrations.uninstrument()) == ["fake1", "fake2"] + assert one.uninstalls == 1 and two.uninstalls == 1 + assert integrations.active() == () + + +def test_a_failing_install_is_skipped_and_the_others_still_install(monkeypatch, caplog): + broken = register(monkeypatch, FakeAdapter(name="broken", module="fw1", fail_install=True)) + good = register(monkeypatch, FakeAdapter(name="good", module="fw2")) + try: + with caplog.at_level(logging.WARNING, logger=INTEGRATIONS_LOGGER): + assert integrations.instrument("broken") == () + assert integrations.instrument("good") == ("good",) + assert broken.installs == 1 and good.installs == 1 + assert integrations.active() == ("good",) + assert "broken" in caplog.text + finally: + integrations.uninstrument() + + +def test_a_failing_install_raises_under_strict(monkeypatch): + register(monkeypatch, FakeAdapter(name="broken", module="fw1", fail_install=True)) + strict_on(monkeypatch) + with pytest.raises(RuntimeError, match="exploded"): + integrations.instrument("broken") + assert integrations.active() == () + + +def test_an_adapter_missing_install_is_rejected(monkeypatch, caplog): + register(monkeypatch, types.SimpleNamespace(name="halfbaked", module="fw"), name="halfbaked") + with caplog.at_level(logging.WARNING, logger=INTEGRATIONS_LOGGER): + assert integrations.instrument("halfbaked") == () + assert "halfbaked" in caplog.text + + +# --------------------------------------------------------------------------- +# auto-detection +# --------------------------------------------------------------------------- + +def test_autodetect_ignores_a_framework_that_is_not_imported(fake): + assert "fakeframework" not in sys.modules + assert "fake" not in integrations.instrument() + + +def test_autodetect_picks_up_a_framework_that_is_imported(fake, monkeypatch): + monkeypatch.setitem(sys.modules, "fakeframework", types.ModuleType("fakeframework")) + assert "fake" in integrations.instrument() + assert fake.installs == 1 + + +def test_autodetect_ignores_an_installed_but_unimported_framework(fake, monkeypatch, tmp_path): + """The test that actually separates `sys.modules` from `find_spec`. + + A framework merely *installed* must not be instrumented — the earlier test + passes against a `find_spec` implementation too, because a stub in + `sys.modules` has no spec to find. Here the module is genuinely importable + and genuinely not imported, which is the common case on any machine with + more than one framework in its virtualenv. + """ + import importlib.util + + (tmp_path / "unimported_framework.py").write_text("raise AssertionError('imported!')\n") + monkeypatch.syspath_prepend(str(tmp_path)) + monkeypatch.setitem(integrations._DETECT, "fake", ("unimported_framework",)) + + assert importlib.util.find_spec("unimported_framework") is not None + assert "unimported_framework" not in sys.modules + + assert "fake" not in integrations.instrument() + assert fake.installs == 0 + assert "unimported_framework" not in sys.modules + + +def test_autodetect_never_imports_the_framework(fake, monkeypatch): + """`sys.modules`, not `find_spec`. + + `find_spec` would say "installed" for a framework the user has not imported, + and acting on that means charging 200ms of transitive imports to a library + that was supposed to be invisible. + """ + imported = [] + real_import_module = integrations.importlib.import_module + + def spy(name, *args, **kwargs): + imported.append(name) + return real_import_module(name, *args, **kwargs) + + monkeypatch.setattr(integrations.importlib, "import_module", spy) + integrations.instrument() + assert "fakeframework" not in imported + + +# --------------------------------------------------------------------------- +# install / uninstall discipline +# --------------------------------------------------------------------------- + +def original_function(x): + return x * 2 + + +def test_uninstall_restores_the_exact_original_object(): + # `is`, not `==`. Restoring by re-importing hands back whatever the current + # value is, which is how two instrumentation libraries un-patch each other. + holder = types.SimpleNamespace(fn=original_function) + patcher = _core.Patcher() + patcher.patch(holder, "fn", _core.wrap_callable(holder.fn, before=lambda *a, **k: None)) + assert holder.fn is not original_function + assert holder.fn(3) == 6 + patcher.restore_all() + assert holder.fn is original_function + + +def test_patching_an_absent_attribute_removes_it_again(): + holder = types.SimpleNamespace() + patcher = _core.Patcher() + patcher.patch(holder, "hook", lambda: None) + assert hasattr(holder, "hook") + patcher.restore_all() + assert not hasattr(holder, "hook") + + +def test_uninstall_declines_when_a_third_party_patched_on_top(caplog): + holder = types.SimpleNamespace(fn=original_function) + patcher = _core.Patcher() + patcher.patch(holder, "fn", _core.wrap_callable(holder.fn, before=lambda *a, **k: None)) + + def third_party(x): + return x + + holder.fn = third_party # somebody else instrumented after us + + with caplog.at_level(logging.WARNING, logger=INTEGRATIONS_LOGGER): + patcher.restore_all() + + # Restoring here would silently delete their patch. + assert holder.fn is third_party + assert "not restoring" in caplog.text + + +def test_every_wrapper_carries_the_original(): + wrapped = _core.wrap_callable(original_function, before=lambda *a, **k: None) + assert _core.is_wrapped(wrapped) + assert _core.unwrap(wrapped) is original_function + assert wrapped.__name__ == "original_function" + assert _core.unwrap(original_function) is original_function + + +# --------------------------------------------------------------------------- +# failure policy +# --------------------------------------------------------------------------- + +def test_a_raising_hook_leaves_the_call_untouched_and_logs_once(caplog): + def before(*args, **kwargs): + raise RuntimeError("hook boom") + + wrapped = _core.wrap_callable(original_function, before=before) + with caplog.at_level(logging.WARNING, logger=INTEGRATIONS_LOGGER): + assert wrapped(21) == 42 + warnings_logged = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings_logged) == 1 + assert warnings_logged[0].exc_info is not None + + +def test_a_raising_hook_reraises_under_strict(monkeypatch): + def before(*args, **kwargs): + raise RuntimeError("hook boom") + + wrapped = _core.wrap_callable(original_function, before=before) + strict_on(monkeypatch) + with pytest.raises(RuntimeError, match="hook boom"): + wrapped(21) + + +def test_a_hot_failing_hook_is_logged_once_then_disabled(caplog): + calls = [] + + def before(*args, **kwargs): + calls.append(1) + raise RuntimeError("boom") + + wrapped = _core.wrap_callable(original_function, before=before) + with caplog.at_level(logging.DEBUG, logger=INTEGRATIONS_LOGGER): + for _ in range(10): + assert wrapped(1) == 2 + + # Stops being called at all: a broken adapter costs one log line, not 40% + # of the process. + assert len(calls) == _core._MAX_FAILURES + assert len([r for r in caplog.records if r.levelno == logging.WARNING]) == 1 + assert len([r for r in caplog.records if r.levelno == logging.ERROR]) == 1 + + +def test_safe_does_not_swallow_baseexception(): + """CancelledError is a BaseException, and eating it breaks cancellation. + + A `safe()` that caught `BaseException` would pass every other test in this + file while silently making every instrumented async application unkillable. + """ + + @_core.safe + def handler(): + raise KeyboardInterrupt("ctrl-c") + + with pytest.raises(KeyboardInterrupt): + handler() + + +def test_safe_swallows_exception_and_returns_none(caplog): + @_core.safe + def handler(): + raise ValueError("nope") + + with caplog.at_level(logging.WARNING, logger=INTEGRATIONS_LOGGER): + assert handler() is None + assert caplog.records + + +def test_the_structural_guarantee_return_value(): + """before AND after both raising still returns the original's value.""" + sentinel = object() + + def boom(*args, **kwargs): + raise RuntimeError("hook") + + wrapped = _core.wrap_callable(lambda: sentinel, before=boom, after=boom) + assert wrapped() is sentinel + + +def test_the_structural_guarantee_exception_identity(): + """The original exception object comes back out unchanged.""" + original_exc = ValueError("the real failure") + + def raiser(): + raise original_exc + + def boom(*args, **kwargs): + raise RuntimeError("hook") + + wrapped = _core.wrap_callable(raiser, before=boom, after=boom, on_error=boom) + with pytest.raises(ValueError) as excinfo: + wrapped() + # `is`, not a message match: a wrapper that re-raised a copy would break + # `except MyError as e: e.retry_after`. + assert excinfo.value is original_exc + + +def test_on_error_sees_the_exception_and_the_before_context(): + seen = {} + + def before(*args, **kwargs): + return {"args": args} + + def on_error(ctx, exc): + seen["ctx"] = ctx + seen["exc"] = exc + + failure = KeyError("k") + + def raiser(x): + raise failure + + wrapped = _core.wrap_callable(raiser, before=before, on_error=on_error) + with pytest.raises(KeyError): + wrapped(7) + assert seen["ctx"] == {"args": (7,)} + assert seen["exc"] is failure + + +def test_after_sees_the_result(): + seen = {} + wrapped = _core.wrap_callable( + original_function, + before=lambda *a, **k: "ctx", + after=lambda ctx, result: seen.update(ctx=ctx, result=result), + ) + assert wrapped(4) == 8 + assert seen == {"ctx": "ctx", "result": 8} + + +# --------------------------------------------------------------------------- +# payload discipline +# --------------------------------------------------------------------------- + +def test_truncate_marks_and_bounds_a_long_string(): + out = _core.truncate("x" * 20_000, 100) + assert len(out) == 100 + assert out.endswith(_core.TRUNCATION_MARKER) + + +def test_truncate_recurses_into_containers(): + out = _core.truncate({"a": ["y" * 500]}, 50) + assert out["a"][0].endswith(_core.TRUNCATION_MARKER) + + +def test_truncate_leaves_small_values_alone(): + value = {"a": 1, "b": "short", "c": None, "d": True} + assert _core.truncate(value) == value + + +def test_truncate_expands_a_mapping_that_is_not_a_dict(): + # `isinstance(value, dict)` missed every mapping a framework actually hands + # us that is not literally a dict, and those fell through to the repr + # branch. A tool's JSON schema then reached the events store as the STRING + # "mappingproxy({'title': 'From Unit', 'type': 'string'})" — valid JSON + # holding a Python repr, so `JSONExtract` over it finds nothing and the + # field is unqueryable rather than merely ugly. Seen in a real stored row: + # a crewai `model_request.tools[0]…properties.from_unit`. + # `MappingProxyType` is what `model_json_schema()` and any frozen config + # hands back, so this is the common case, not an exotic one. + schema = types.MappingProxyType({"title": "From Unit", "type": "string"}) + assert _core.truncate({"from_unit": schema}) == { + "from_unit": {"title": "From Unit", "type": "string"} + } + assert _core.truncate({"p": collections.ChainMap({"a": 1})}) == {"p": {"a": 1}} + + +def test_truncate_expands_a_dataclass_and_a_pydantic_model(): + # Every framework hands us these — a tool's argument model, its structured + # return, a settings object on a request — and they have no JSON shape by + # the container checks, so they were rendered: `Point(x=1, y=2)`, a Python + # repr inside a JSON string that `JSONExtract` cannot read and the dashboard + # cannot filter on. + @dataclasses.dataclass + class Point: + x: int + y: int + + class Model: # duck-typed pydantic v2: what matters is `model_dump` + def model_dump(self): + return {"city": "Faro", "celsius": 21} + + assert _core.truncate({"p": Point(1, 2)}) == {"p": {"x": 1, "y": 2}} + assert _core.truncate({"m": Model()}) == {"m": {"city": "Faro", "celsius": 21}} + # and nested inside a container, which is where they actually arrive + assert _core.truncate([Point(1, 2)]) == [{"x": 1, "y": 2}] + + +def test_truncate_falls_back_to_repr_when_unwrapping_raises(): + # `model_dump` and `getattr` both run the CALLER'S code — a validator, a + # property that touches the network. A telemetry library must not turn that + # into an exception in someone's agent loop, and `repr` is exactly what + # would have happened before any of this existed. + class Exploding: + def model_dump(self): + raise RuntimeError("boom") + + def __repr__(self): + return "<Exploding>" + + assert _core.truncate({"e": Exploding()}) == {"e": "<Exploding>"} + + +def test_truncate_does_not_mistake_a_class_for_an_instance(): + # `dataclasses.is_dataclass` is true of the CLASS as well as its instances, + # and `fields()` on the class would render a type as though it were data. + @dataclasses.dataclass + class Point: + x: int + + out = _core.truncate({"c": Point}) + assert isinstance(out["c"], str) and "class" in out["c"] + + +def test_truncate_still_reprs_an_object_with_no_json_shape(): + # The counterweight: widening to the ABCs must not turn the repr branch + # into dead code. An object that is neither a mapping nor a sequence has no + # JSON shape and rendering it is the only thing left to do. + class Opaque: + def __repr__(self): + return "<Opaque>" + + assert _core.truncate({"o": Opaque()}) == {"o": "<Opaque>"} + # And a str is a Sequence — it must keep taking the string path, not be + # exploded into a list of characters. + assert _core.truncate("hello") == "hello" + assert _core.truncate(b"raw") == "raw" + + +def test_payload_flags_truncation(): + out = _core.payload({"fw_prompt": "z" * 100_000}) + assert out["fw_truncated"] is True + assert len(out["fw_prompt"]) <= _core.FIELD_LIMIT + + +def test_payload_enforces_the_event_budget(): + fields = {f"fw_{i}": "q" * _core.FIELD_LIMIT for i in range(20)} + out = _core.payload(fields) + assert out["fw_truncated"] is True + total = sum(len(v) for v in out.values() if isinstance(v, str)) + assert total <= _core.EVENT_BUDGET + + +def test_payload_leaves_a_small_event_alone(): + assert _core.payload({"fw_node": "retrieve"}) == {"fw_node": "retrieve"} + + +def test_fw_fields_namespaces_and_drops_none(): + assert _core.fw_fields(run_id="r1", node="retrieve", tags=None) == { + "fw_run_id": "r1", + "fw_node": "retrieve", + } + + +def test_fw_fields_namespaces_a_name_that_would_shadow_a_column(): + # `_schema._build()` merges extras last, so a bare `tool_name` extra would + # overwrite the declared one and change the promoted column. + assert _core.fw_fields(tool_name="sneaky") == {"fw_tool_name": "sneaky"} + + +def test_fw_fields_passes_the_deliberate_top_level_names_through(): + out = _core.fw_fields(duration_ms=12, request_id="req-1", usage={"total_tokens": 3}) + assert out == {"duration_ms": 12, "request_id": "req-1", "usage": {"total_tokens": 3}} + + +def test_forbidden_extras_is_derived_from_the_schema(): + # Derived, not hand-listed, so adding a field to _schema.py cannot leave a + # stale copy here. + for name in ("tool_name", "model", "outcome", "input_tokens", "error", "session_id"): + assert name in _core.FORBIDDEN_EXTRAS + for name in ("request_id", "duration_ms", "usage", "framework"): + assert name not in _core.FORBIDDEN_EXTRAS + + +def test_guard_extras_strips_a_shadowing_name(caplog): + with caplog.at_level(logging.WARNING, logger=INTEGRATIONS_LOGGER): + out = _core.guard_extras({"tool_name": "sneaky", "fw_node": "ok"}) + assert out == {"fw_node": "ok"} + assert "tool_name" in caplog.text + + +def test_guard_extras_raises_under_strict(monkeypatch): + strict_on(monkeypatch) + with pytest.raises(ValueError, match="tool_name"): + _core.guard_extras({"tool_name": "sneaky"}) + + +@pytest.mark.parametrize( + "raw,expected", + [ + (" retrieve\n documents ", "retrieve documents"), + ("550e8400-e29b-41d4-a716-446655440000", "main"), + ("550e8400e29b41d4a716446655440000", "main"), + ("deadbeefdeadbeefdead", "main"), + ("", "main"), + (" ", "main"), + (None, "main"), + ("Researcher", "Researcher"), + ("node_" * 40, ("node_" * 40)[:64]), + # A readable name carrying a per-run id. `_looks_like_id` fires only on + # a value that is an id all the way through, so these went in untouched + # — one distinct value per run, into the LowCardinality column that is + # the primary dashboard facet. That is the same poisoning the + # whole-string guard exists to prevent, reached by the shape frameworks + # actually produce and the docs already warn about. + ("agent-550e8400e29b41d4a716446655440000", "agent"), + ("task-550e8400-e29b-41d4-a716-446655440000", "task"), + ("crew_550e8400-e29b-41d4-a716-446655440000_worker", "crew worker"), + # ...and the counterweight: stripping must not eat a readable name that + # merely contains short hex-ish or numeric segments. + ("agent-v2", "agent-v2"), + ("step-3", "step-3"), + ("node_a1b2", "node_a1b2"), + ("deadbeef", "deadbeef"), + ], +) +def test_normalize_agent_id(raw, expected): + # agent_id is LowCardinality(String) and the primary dashboard facet; a UUID + # in it poisons that facet for every session in the project. + assert _core.normalize_agent_id(raw, "main") == expected + + +def test_ms_is_always_an_int(): + # The server stores duration_ms as u32 and its JSON parser drops floats, + # which silently NULLs the column. + from datetime import timedelta + + assert _core.ms(1.2345) == 1234 + assert isinstance(_core.ms(0.5), int) + assert _core.ms(timedelta(milliseconds=250)) == 250 + assert _core.ms(-3.0) == 0 + + +def test_framework_fields(): + out = _core.framework_fields("langchain", "definitely-not-installed") + assert out["framework"] == "langchain" + assert out["integration_version"] == failproofai_sdk.__version__ + assert "framework_version" not in out + + +# --------------------------------------------------------------------------- +# RunTracker +# --------------------------------------------------------------------------- + +def test_run_tracker_brackets_an_agent(events): + tracker = _core.RunTracker("fake") + tracker.start_agent("run-1", agent_id="researcher", session_id="s-1", goal="find it") + tracker.end_agent("run-1", outcome="success") + assert events.types() == ["agent_start", "agent_end"] + start, end = events.entries + assert start["session_id"] == "s-1" + assert start["agent_id"] == "researcher" + assert start["goal"] == "find it" + assert "parent_id" not in start + assert end["outcome"] == "success" + + +def test_run_tracker_uses_a_uuid_run_id_as_a_key_not_as_an_agent_id(events): + tracker = _core.RunTracker("fake") + tracker.start_agent( + "run-1", agent_id="550e8400-e29b-41d4-a716-446655440000", session_id="s-1" + ) + assert events.last()["agent_id"] == "main" + + +def test_run_tracker_nests_on_the_parent_key(events): + tracker = _core.RunTracker("fake") + tracker.start_agent("root", agent_id="crew", session_id="s-1") + tracker.start_agent("child", agent_id="researcher", parent_key="root") + child = events.entries[-1] + assert child["parent_id"] == "crew" + assert child["session_id"] == "s-1" + + +def test_run_tracker_walks_a_chain_of_non_agent_runs(events): + """The framework's parent_run_id chain is a better parent than a contextvar + stack: it survives task hops and thread pools.""" + tracker = _core.RunTracker("fake") + tracker.start_agent("agent-run", agent_id="graph", session_id="s-1") + tracker.link("chain-run", "agent-run") + tracker.link("inner-run", "chain-run") + tracker.emit( + "tool_use", "tool-run", parent_key="inner-run", tool_name="search", tool_call_id="t1" + ) + tool = events.entries[-1] + assert tool["type"] == "tool_use" + assert tool["agent_id"] == "graph" + assert tool["session_id"] == "s-1" + + +def test_run_tracker_joins_a_hand_written_scope(events): + """Step 3 of `identity()` — the whole interop story. + + An adapter running inside a hand-written `with failproofai_sdk.agent("planner")` + must land in the SAME session with parent_id="planner", or the customer gets + two disconnected trees for one run. + """ + tracker = _core.RunTracker("fake") + with failproofai_sdk.agent("planner", session_id="s-outer", goal="q"): + tracker.start_agent("run-1", agent_id="researcher") + tracker.emit("tool_use", "run-1", tool_name="search", tool_call_id="t1") + tracker.end_agent("run-1") + # A run the tracker has never seen — a callback that arrived without a + # start, which every framework produces eventually — still lands on the + # open scope rather than being dropped or given a session of its own. + tracker.emit("tool_use", "never-registered", tool_name="lookup", tool_call_id="t2") + + assert events.types() == [ + "agent_start", + "agent_start", + "tool_use", + "agent_end", + "tool_use", + "agent_end", + ] + assert all(e["session_id"] == "s-outer" for e in events.entries) + researcher = events.entries[1] + assert researcher["agent_id"] == "researcher" + assert researcher["parent_id"] == "planner" + assert events.entries[2]["agent_id"] == "researcher" + assert events.entries[4]["agent_id"] == "planner" + + +def test_run_tracker_does_not_invent_a_parent_inside_a_bare_session(events): + """A `session()` scope binds no agent, so an adapter agent there is a root. + + Claiming `parent_id="main"` would point at an agent that never emitted an + `agent_start`, and the dashboard answers that by synthesizing a permanent + extra lane that is `ongoing` forever. + """ + tracker = _core.RunTracker("fake") + with failproofai_sdk.session("s-outer"): + tracker.start_agent("run-1", agent_id="researcher") + start = events.entries[0] + assert start["session_id"] == "s-outer" + assert "parent_id" not in start + + +def test_run_tracker_drops_unresolvable_events_and_warns_once(events, caplog): + tracker = _core.RunTracker("fake") + with caplog.at_level(logging.WARNING, logger=INTEGRATIONS_LOGGER): + for i in range(5): + tracker.emit("tool_use", f"unknown-{i}", tool_name="t", tool_call_id="x") + assert events.entries == [] + # Dropped rather than given a synthesized session id: a synthetic session + # splits one run into many, which is a silent wrong answer. + resolution_warnings = [r for r in caplog.records if "could not resolve" in r.getMessage()] + assert len(resolution_warnings) == 1 + + +def test_run_tracker_is_bounded(events): + tracker = _core.RunTracker("fake", max_open=10) + for i in range(200): + tracker.start_agent(f"run-{i}", agent_id="worker", session_id="s-1") + assert len(tracker.open_agents()) <= 10 + + +def test_run_tracker_survives_concurrent_start_and_end(events): + """CrewAI dispatches its handlers on a ten-worker pool.""" + tracker = _core.RunTracker("fake") + errors = [] + barrier = threading.Barrier(8) + + def worker(n): + try: + barrier.wait(timeout=10) + for i in range(50): + key = f"{n}-{i}" + tracker.start_agent(key, agent_id="worker", session_id="s-1") + tracker.emit("tool_use", key, tool_name="t", tool_call_id=key) + tracker.end_agent(key) + except BaseException as exc: # noqa: BLE001 - reported, not swallowed + errors.append(exc) + + threads = [threading.Thread(target=worker, args=(n,)) for n in range(8)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=30) + + assert errors == [] + assert tracker.open_agents() == () + assert len(events.entries) == 8 * 50 * 3 + + +def test_run_tracker_close_open_agents(events): + tracker = _core.RunTracker("fake") + tracker.start_agent("a", agent_id="one", session_id="s-1") + tracker.start_agent("b", agent_id="two", parent_key="a") + tracker.close_open_agents() + # A session that dies with an open agent_start renders `ongoing` forever. + assert events.types() == ["agent_start", "agent_start", "agent_end", "agent_end"] + assert [e["agent_id"] for e in events.entries[2:]] == ["two", "one"] + assert all(e["outcome"] == "cancelled" for e in events.entries[2:]) + + +def test_run_tracker_stamps_the_framework_fields(events): + tracker = _core.RunTracker("fake", base_fields=_core.framework_fields("fake")) + tracker.start_agent("run-1", agent_id="worker", session_id="s-1") + assert events.last()["framework"] == "fake" + assert events.last()["integration_version"] == failproofai_sdk.__version__ + + +def test_run_tracker_base_fields_cannot_shadow_a_declared_field(events, caplog): + tracker = _core.RunTracker("fake", base_fields={"tool_name": "hijacked"}) + tracker.start_agent("run-1", agent_id="worker", session_id="s-1") + with caplog.at_level(logging.WARNING, logger=INTEGRATIONS_LOGGER): + tracker.emit("tool_use", "run-1", tool_name="real", tool_call_id="t1") + assert events.last()["tool_name"] == "real" + + +def test_run_tracker_truncates_a_huge_payload(events): + tracker = _core.RunTracker("fake") + tracker.start_agent("run-1", agent_id="worker", session_id="s-1") + tracker.emit( + "tool_use", "run-1", tool_name="t", tool_call_id="t1", input={"q": "x" * 100_000} + ) + assert len(events.last()["input"]["q"]) <= _core.FIELD_LIMIT + + +def test_run_tracker_never_raises_into_the_caller(events, caplog): + tracker = _core.RunTracker("fake") + tracker.start_agent("run-1", agent_id="worker", session_id="s-1") + with caplog.at_level(logging.WARNING, logger=INTEGRATIONS_LOGGER): + # `timestamp` is reserved, so the emit raises inside the SDK. + tracker.emit("tool_use", "run-1", tool_name="t", tool_call_id="t1", timestamp="nope") + assert "failproofai_sdk" in caplog.text + + +def test_run_tracker_raises_into_the_caller_under_strict(events, monkeypatch): + tracker = _core.RunTracker("fake") + tracker.start_agent("run-1", agent_id="worker", session_id="s-1") + strict_on(monkeypatch) + with pytest.raises(ValueError): + tracker.emit("tool_use", "run-1", tool_name="t", tool_call_id="t1", timestamp="nope") + + +def test_run_tracker_does_not_touch_contextvars(events): + """Shape B must never bind identity onto the context. + + `ContextVar.reset(token)` raises across asyncio tasks as well as threads, so + a token cannot be held between two callbacks — and a scope entered in + `on_tool_start` that is never exited misattributes every later event in the + process. + """ + from failproofai_sdk import _context + + tracker = _core.RunTracker("fake") + tracker.start_agent("run-1", agent_id="worker", session_id="s-1") + assert _context.snapshot() == (None, ()) + tracker.emit("tool_use", "run-1", tool_name="t", tool_call_id="t1") + assert _context.snapshot() == (None, ()) + tracker.end_agent("run-1") + assert _context.snapshot() == (None, ()) + + +# --------------------------------------------------------------------------- +# _compat +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "text,expected", + [ + ("1.5.2", (1, 5, 2)), + ("2.0.0b1", (2, 0, 0)), + ("0.14.23.post1", (0, 14, 23)), + ("1.2.dev0", (1, 2)), + ("1", (1,)), + ("", ()), + ("nonsense", ()), + ], +) +def test_parse_version_is_naive_on_purpose(text, expected): + # No `packaging`: `import failproofai_sdk` is zero-dependency. + assert _compat.parse_version(text) == expected + + +def test_version_comparison_orders_as_expected(): + assert _compat.parse_version("1.5") >= _compat.parse_version("1.4.7") + assert _compat.parse_version("1.5.2") < _compat.parse_version("2") + + +def test_missing_framework_raises_with_the_install_command(): + # Tier 1. Instrumenting is an explicit user action, so silence is never right. + with pytest.raises(ImportError) as excinfo: + _compat.require_module("definitely_not_a_module", dist="nope", extra="langchain") + assert "pip install 'failproofai_sdk[langchain]'" in str(excinfo.value) + + +def test_require_module_returns_the_module(): + assert _compat.require_module("json", dist="json", extra="langchain").__name__ == "json" + + +def test_version_in_range_is_silent(recwarn): + assert _compat.check_version("self", "failproofai_sdk", minimum="0.0.1", below="99") is True + assert not [w for w in recwarn if issubclass(w.category, _compat.FailproofAICompatWarning)] + + +def test_version_out_of_range_warns_once(): + # Tier 2: warn, then best effort. A ceiling is what stops a future major + # from making the adapter stop recording while raising nothing. + with pytest.warns(_compat.FailproofAICompatWarning, match="newer"): + assert _compat.check_version("self", "failproofai_sdk", below="0.0.1") is False + + # Deduplicated: these fire from install() *and* from hot callbacks, so a + # per-call warning on a chatty framework is its own outage. + with warnings.catch_warnings(record=True) as second: + warnings.simplefilter("always") + _compat.check_version("self", "failproofai_sdk", below="0.0.1") + assert not [w for w in second if issubclass(w.category, _compat.FailproofAICompatWarning)] + + +def test_an_uninstallable_distribution_is_best_effort(): + assert _compat.check_version("x", "definitely-not-installed", minimum="99") is True + + +def test_strict_integrations_promotes_a_warning_to_an_exception(monkeypatch): + strict_on(monkeypatch, "FAILPROOFAI_SDK_STRICT_INTEGRATIONS") + with pytest.raises(_compat.FailproofAICompatWarning): + _compat.check_version("self", "failproofai_sdk", minimum="99") + + +def test_a_failing_capability_probe_disables_one_hook_only(): + # Tier 3. + with pytest.warns(_compat.FailproofAICompatWarning, match="on_interrupt"): + assert _compat.probe("langchain", "on_interrupt", lambda: 1 / 0) is False + assert _compat.probe("langchain", "on_tool_start", lambda: True) is True + + +def test_a_probe_returning_false_warns(): + with pytest.warns(_compat.FailproofAICompatWarning, match="does not provide"): + assert _compat.probe("langchain", "on_resume", lambda: None) is False diff --git a/sdk/python/tests/test_no_customer_identifiers.py b/sdk/python/tests/test_no_customer_identifiers.py new file mode 100644 index 000000000..60ec4b51b --- /dev/null +++ b/sdk/python/tests/test_no_customer_identifiers.py @@ -0,0 +1,192 @@ +"""This package is PUBLIC and its wheel is published to PyPI. + +It came out of a private monorepo, where naming a live tenant in a fixture or +leaving an internal hostname in a docstring was harmless. Here it is permanent: +a PyPI version cannot be recalled or reused, so anything that ships once ships +forever. The sibling `fp-cli` hit exactly this during its own move — a real +customer's slug and company name reached the tree in a bulk copy — which is why +this tripwire exists on both packages. + +Customer names are held as SHA-256 digests, never in the clear. A deny-list that +spells out the name it exists to keep out of a public wheel publishes that name +just as surely as the fixture did, and this file ships in the sdist. Our OWN +names stay readable: they are already in LICENSE and pyproject.toml, so there is +nothing to withhold, and a contributor who trips over one needs to see which it +was. + +The match is over SUBSTRINGS of each token, not whole tokens, because the +original leak was a slug *and* a company name built from it — one name inside a +longer one. To add an identifier: + + python3 -c 'import hashlib,sys;print(hashlib.sha256(sys.argv[1].lower().encode()).hexdigest())' NAME +""" + +from __future__ import annotations + +import functools +import hashlib +import pathlib +import re + +import failproofai_sdk + +PKG = pathlib.Path(failproofai_sdk.__file__).resolve().parent +ROOT = PKG.parent + +# Our own organisation names — public in this repo already, so in the clear. A +# fixture must still not use them: they identify a real tenant on a real deployment. +FORBIDDEN_OWN = { + "exosphere", + "exospherehost", +} + +# Customer / vendor identifiers, digest → what it is (never the name itself). +# Kept in step with fp-cli/tests/test_no_customer_identifiers.py. +FORBIDDEN_DIGESTS = { + "140bd3c7a8606c97e18fb1f01c3a94f558eab6e2c5b27a56f9c3f5a940d8e2fd": "a customer's tenant slug", +} + +# Internal artefacts from the private monorepo. Unlike the names above these are +# not confidential so much as WRONG to ship: a dev credential in a public +# quickstart gets pasted into production, and an internal-only table name in a +# docstring is an invitation to query something that does not exist for users. +FORBIDDEN_INTERNALS = { + "dev-admin-key": "the local-dev admin credential", + "agenteye-enterprise": "the private customer-release org", + "clickhouse": "an internal storage detail users never touch", + "org_ch_secret": "an internal server secret name", +} + +# The vocabulary fixtures are supposed to use. +SANCTIONED_FIXTURE_ORGS = {"acme", "globex", "example", "initech", "umbrella"} + +_TOKEN = re.compile(r"[a-z0-9]+") +_URL = re.compile(r"https?://([a-z0-9.-]+)", re.I) + +# Substring lengths considered when hashing. The floor keeps the scan off two- and +# three-letter noise; the ceiling bounds the work on long tokens (hex digests, base64). +_MIN_LEN = 5 +_MAX_LEN = 24 + + +@functools.lru_cache(maxsize=None) +def _digest(text: str) -> str: + return hashlib.sha256(text.encode()).hexdigest() + + +def _hashed_hits(text: str, needles: frozenset[str] | None = None) -> set[str]: + """Digests from ``needles`` whose plaintext appears anywhere in ``text``. + + ``text`` need not be lowercased by the caller. Returns digests, not matched + text: a failure message must not echo the identifier into a public CI log. + """ + want = FORBIDDEN_DIGESTS.keys() if needles is None else needles + found = set() + for token in set(_TOKEN.findall(text.lower())): + for start in range(len(token)): + stop = min(len(token), start + _MAX_LEN) + for end in range(start + _MIN_LEN, stop + 1): + d = _digest(token[start:end]) + if d in want: + found.add(d) + return found + + +def _sources() -> list[pathlib.Path]: + out = [] + for base in (PKG, ROOT / "tests"): + out.extend(p for p in base.rglob("*.py") if "__pycache__" not in p.parts) + for name in ("README.md", "CHANGELOG.md", "pyproject.toml"): + p = ROOT / name + if p.is_file(): + out.append(p) + for p in (ROOT / "skill").rglob("*"): + if p.is_file() and p.suffix in {".md", ".yaml", ".yml"}: + out.append(p) + return out + + +def _scannable() -> list[pathlib.Path]: + """Everything but this file, which names our own orgs to deny them.""" + return [p for p in _sources() if p.name != pathlib.Path(__file__).name] + + +def _scannable_for_own_names() -> list[pathlib.Path]: + """As above, minus the one file our own org name legitimately belongs in. + + `pyproject.toml`'s `authors` email is the published maintainer contact — it + is on the PyPI project page by design, and `fp-cli` publishes the same one. + It is still scanned for customer digests and internal artefacts below; only + the our-own-name rule is lifted, because that rule exists to keep a real + tenant out of a FIXTURE, not to redact the maintainer. + """ + return [p for p in _scannable() if p.name != "pyproject.toml"] + + +def test_no_real_customer_or_vendor_identifiers(): + hits = [] + for p in _scannable_for_own_names(): + for i, line in enumerate(p.read_text(encoding="utf-8", errors="replace").split("\n"), 1): + lowered = line.lower() + for needle in FORBIDDEN_OWN: + if needle in lowered: + hits.append(f"{p.relative_to(ROOT)}:{i}: {needle}") + # Customer digests are checked over EVERY file, pyproject.toml included — + # the exemption above is only for our own maintainer contact. + for p in _scannable(): + for i, line in enumerate(p.read_text(encoding="utf-8", errors="replace").split("\n"), 1): + # Report the location and what class of identifier it is — never the name. + for digest in _hashed_hits(line): + hits.append(f"{p.relative_to(ROOT)}:{i}: {FORBIDDEN_DIGESTS[digest]}") + assert not hits, ( + "real organisation names must not appear in a public package — use a fixture " + f"name such as {sorted(SANCTIONED_FIXTURE_ORGS)}:\n " + "\n ".join(hits) + ) + + +def test_no_internal_artefacts_from_the_private_monorepo(): + """Credentials and internal names that came across in the move.""" + hits = [] + for p in _scannable(): + for i, line in enumerate(p.read_text(encoding="utf-8", errors="replace").split("\n"), 1): + lowered = line.lower() + for needle, what in FORBIDDEN_INTERNALS.items(): + if needle in lowered: + hits.append(f"{p.relative_to(ROOT)}:{i}: {what}") + assert not hits, "internal artefacts in a public package:\n " + "\n ".join(hits) + + +def test_the_scan_actually_has_files_to_scan(): + """Keeps the assertions above from passing vacuously if the layout moves.""" + files = _sources() + assert len(files) > 15, ( + f"only {len(files)} files scanned — the walk is not finding the package" + ) + assert any(p.name == "_writer.py" for p in files) + assert any(p.parent.name == "tests" for p in files) + + +def test_the_hashed_scan_matches_substrings_and_only_them(): + """The digests are opaque, so prove the matcher on a planted, invented name. + + Without this, an off-by-one in the substring window turns the whole hashed + deny-list into an assertion that passes because it matches nothing. + """ + planted = frozenset({_digest("quuxcorp")}) + assert _hashed_hits("slug: quuxcorp", planted) == planted # bare token + assert _hashed_hits('name: "QuuxcorpInc"', planted) == planted # inside a longer name + assert _hashed_hits("host: quuxcorp-prod.example.com", planted) == planted + assert not _hashed_hits("slug: acme, name: Globex Corp", planted) # sanctioned fixtures + assert not _hashed_hits("quux corp", planted) # not one token + + +def test_no_internal_hostnames_leaked(): + """A customer's deployment hostname identifies them as surely as their name.""" + hits = [] + for p in _scannable(): + for i, line in enumerate(p.read_text(encoding="utf-8", errors="replace").split("\n"), 1): + for host in _URL.findall(line): + labels = host.lower().split(".") + if any(label in FORBIDDEN_OWN for label in labels) or _hashed_hits(host): + hits.append(f"{p.relative_to(ROOT)}:{i}") + assert not hits, f"internal/customer hostnames in a public package: {hits}" diff --git a/sdk/python/tests/test_packaging.py b/sdk/python/tests/test_packaging.py new file mode 100644 index 000000000..d8969e566 --- /dev/null +++ b/sdk/python/tests/test_packaging.py @@ -0,0 +1,169 @@ +"""Packaging contracts, read from `importlib.metadata` — never from pyproject. + +Two reasons this file never opens `pyproject.toml`: + +1. `tomllib` is 3.11+ and this SDK's floor is 3.10, so parsing it here would + make the packaging tests skip exactly on the leg that catches 3.10 problems. +2. `pyproject.toml` is the *input*. What ships is the wheel's `METADATA`, and + the failure mode these tests exist to catch — an extra that silently drags + the wrong `failproofai_sdk` off PyPI — happens at install time, from METADATA. + +The rules being enforced: + +* **zero runtime dependencies.** `import failproofai_sdk` must work in an environment + with nothing else installed, so every `Requires-Dist` carries an + `extra == "..."` marker. +* **no self-referential extra.** On public PyPI `failproofai_sdk` is the *CLI*; a + `Requires-Dist: failproofai_sdk[...]` would install the CLI over this SDK. This is + also why the adapters ship as extras on one distribution rather than as + `failproofai_sdk-langgraph` and friends. +* **no `[all]` extra** — a resolution bomb that exists only for CI's + convenience. +* **the extras and the adapter registry agree.** They are two hand-maintained + lists of the same set, in two files, with no codegen between them. +""" + +import re +import os +import subprocess +from pathlib import Path + +import failproofai_sdk +import sys +import textwrap + +import importlib.metadata as metadata + +import pytest + +from failproofai_sdk import integrations + +DIST = "failproofai_sdk" + + +def requirements() -> list[str]: + return list(metadata.requires(DIST) or []) + + +def declared_extras() -> set[str]: + return set(metadata.distribution(DIST).metadata.get_all("Provides-Extra") or []) + + +def test_the_distribution_is_installed(): + # Guards every other test in this file: `requires()` returns None for a + # dist that is not installed, and `for req in []` passes vacuously. + assert metadata.version(DIST) + assert requirements() + + +def test_every_requirement_is_gated_behind_an_extra(): + ungated = [req for req in requirements() if "extra ==" not in req] + assert ungated == [], ( + "failproofai_sdk must have ZERO runtime dependencies; these would be installed " + f"unconditionally: {ungated}" + ) + + +def test_no_requirement_names_the_retired_distribution(): + for req in requirements(): + name = re.split(r"[\s\[<>=!~;(]", req.strip(), maxsplit=1)[0] + assert name.lower().replace("_", "-") != DIST, ( + f"self-referential requirement {req!r}: on public PyPI `failproofai_sdk` is the " + "CLI, so this would install the CLI over the SDK" + ) + + +def test_there_is_no_all_extra(): + assert "all" not in declared_extras() + + +def test_every_extra_maps_to_a_registered_adapter(): + for extra in declared_extras() - {"dev"}: + # Raises ValueError if the extra is not a name (or alias) the registry + # knows — i.e. an extra nobody can instrument. + assert integrations._canonical(extra) in integrations.available() + + +def test_every_registered_adapter_has_an_extra(): + covered = {integrations._canonical(extra) for extra in declared_extras() - {"dev"}} + assert covered == set(integrations.available()) + + +def test_every_extra_requirement_pins_a_floor_and_a_ceiling(): + # Without a ceiling, a clean build a year from now pulls the next major, the + # callback API shifts, and the adapter stops receiving events while raising + # nothing at all. + for req in requirements(): + if 'extra == "dev"' in req: + continue + assert ">=" in req, f"{req} has no floor" + assert "<" in req, f"{req} has no ceiling" + + +IMPORT_GUARD = textwrap.dedent( + """ + import sys + + baseline = set(sys.modules) + import failproofai_sdk + + added = set(sys.modules) - baseline + third_party = sorted( + name for name in added + if name.split(".")[0] not in sys.stdlib_module_names + and not name.startswith("failproofai_sdk") + ) + integrations = sorted(n for n in sys.modules if n.startswith("failproofai_sdk.integrations")) + + assert not third_party, "import failproofai_sdk pulled in third-party modules: %r" % third_party + assert not integrations, "import failproofai_sdk imported the adapters: %r" % integrations + assert failproofai_sdk._writer is not sys.modules["failproofai_sdk._writer"], "_writer is the module" + print("OK") + """ +) + + +def test_importing_the_package_stays_clean(tmp_path): + """A subprocess, because the assertion is about a *cold* interpreter. + + In-process, every framework any other test file imported is already in + `sys.modules` and the check is vacuous. + """ + # `cwd=tmp_path` is what makes the interpreter cold — away from the source + # tree, so nothing is importable by accident. PYTHONPATH then puts the + # package back deliberately, which is what lets this run in an environment + # where it is not pip-installed (a container running the suite off a mounted + # checkout). Without it the test does not fail loudly, it fails with + # ModuleNotFoundError and looks like a packaging bug that is not there. + env = dict(os.environ, PYTHONPATH=str(Path(failproofai_sdk.__file__).resolve().parents[1])) + result = subprocess.run( + [sys.executable, "-c", IMPORT_GUARD], + capture_output=True, + text=True, + cwd=str(tmp_path), + env=env, + ) + assert result.returncode == 0, result.stderr + assert "OK" in result.stdout + + +def test_instrument_is_lazy_until_it_is_called(): + # `failproofai_sdk.instrument` is a thin wrapper whose import sits *inside* the + # function body; the guard test above proves the module-level import is + # absent, this proves the wrapper still exists to be called. + assert callable(sdk_instrument()) + + +def sdk_instrument(): + import failproofai_sdk + + return failproofai_sdk.instrument + + +@pytest.mark.parametrize("name", ["langchain", "crewai", "llama_index", "pydantic_ai"]) +def test_the_registry_points_at_a_module_inside_this_package(name): + path = integrations._REGISTRY[name] + assert path.startswith("failproofai_sdk.integrations.") + # The registry entry is a STRING, imported on demand. If this ever becomes a + # module object, `import failproofai_sdk` stops being zero-dependency. + assert isinstance(path, str) diff --git a/sdk/python/tests/test_resolver_umbrella.py b/sdk/python/tests/test_resolver_umbrella.py new file mode 100644 index 000000000..9ece12234 --- /dev/null +++ b/sdk/python/tests/test_resolver_umbrella.py @@ -0,0 +1,136 @@ +"""The spool root defaults to the failproofai umbrella, and legacy stays reachable. + +This file used to assert the opposite, and the reason it flipped is worth +keeping: the umbrella was previously behind an ``AGENTEYE_SPOOL_TO_FAILPROOFAI`` +opt-in that ALSO required ``~/.failproofai/custom-agents`` to already exist. +Nothing ever created that directory — not this SDK, not ``failproofaid``, not +either installer — so the second condition was never satisfied and the opt-in +never fired once. It was documented, tested, and dead. + +The rule now: the SDK ships beside ``failproofaid``, which watches BOTH roots, +so on any host running it the default is a no-op that only changes which +directory the files appear in. The old root keeps being watched indefinitely, so +an unupgraded SDK keeps working and batches already spooled there still drain. + +The case that genuinely breaks is a host running the OLDER +``agenteye-collector``, which resolves ``$AGENTEYE_HOME`` or ``~/.agenteye`` and +nothing else. Its escape hatch is ``AGENTEYE_HOME``, which both daemons honour — +that is why it is the escape hatch rather than a new variable of our own. +""" +from pathlib import Path + +import pytest + +from failproofai_sdk import _resolver + + +@pytest.fixture(autouse=True) +def _clean(monkeypatch): + _resolver.set_base_dir(None) + monkeypatch.delenv("AGENTEYE_HOME", raising=False) + monkeypatch.delenv("FAILPROOFAI_HOME", raising=False) + yield + _resolver.set_base_dir(None) + + +# ───────────────────────────────────────────────────────────────────────────── +# The default +# ───────────────────────────────────────────────────────────────────────────── + + +def test_the_default_is_the_failproofai_umbrella(tmp_path, monkeypatch): + """THE CHANGE. A clean machine writes to the umbrella, with nothing set.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + assert _resolver.get_base_dir() == tmp_path / ".failproofai" / "custom-agents" + + +def test_the_umbrella_does_not_have_to_exist_first(tmp_path, monkeypatch): + """The regression that made the old opt-in dead. + + Requiring the directory to pre-exist means it can never be the place a first + batch is written — nothing creates it, so the branch is unreachable. The + writer mkdirs what it is about to write into, so resolution must not care. + """ + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + assert not (tmp_path / ".failproofai").exists() + assert _resolver.get_base_dir() == tmp_path / ".failproofai" / "custom-agents" + + +def test_an_existing_legacy_root_does_not_drag_the_default_back(tmp_path, monkeypatch): + """Presence of ``~/.agenteye`` is not a vote. + + It exists on any machine that merely ran the old CLI once — it holds that + CLI's `cli.json` — so treating it as a signal would pin those machines to + the old path forever despite no collector ever having watched it. + """ + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + (tmp_path / ".agenteye" / "events").mkdir(parents=True) + assert _resolver.get_base_dir() == tmp_path / ".failproofai" / "custom-agents" + + +# ───────────────────────────────────────────────────────────────────────────── +# The escape hatch — the whole safety argument for the change above +# ───────────────────────────────────────────────────────────────────────────── + + +def test_agenteye_home_still_wins_and_is_the_legacy_escape_hatch(tmp_path, monkeypatch): + """A host on the old ``agenteye-collector`` sets this and is unaffected. + + Both daemons honour it, which is exactly why it is the escape hatch and why + no new variable was invented for the job. + """ + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + monkeypatch.setenv("AGENTEYE_HOME", str(tmp_path / ".agenteye")) + assert _resolver.get_base_dir() == tmp_path / ".agenteye" + + +def test_agenteye_home_may_point_anywhere_not_just_the_legacy_root(tmp_path, monkeypatch): + monkeypatch.setenv("AGENTEYE_HOME", str(tmp_path / "somewhere" / "else")) + assert _resolver.get_base_dir() == tmp_path / "somewhere" / "else" + + +def test_an_empty_agenteye_home_is_ignored_rather_than_resolving_to_cwd(tmp_path, monkeypatch): + """``AGENTEYE_HOME=`` in a CI env file must not spool into the repo.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + monkeypatch.setenv("AGENTEYE_HOME", "") + assert _resolver.get_base_dir() == tmp_path / ".failproofai" / "custom-agents" + + +# ───────────────────────────────────────────────────────────────────────────── +# Precedence and the umbrella path itself +# ───────────────────────────────────────────────────────────────────────────── + + +def test_set_base_dir_beats_every_environment_variable(tmp_path, monkeypatch): + monkeypatch.setenv("AGENTEYE_HOME", str(tmp_path / "env")) + _resolver.set_base_dir(tmp_path / "explicit") + assert _resolver.get_base_dir() == tmp_path / "explicit" + + +def test_failproofai_home_moves_the_umbrella(tmp_path, monkeypatch): + """Mirrors ``FAILPROOFAI_HOME`` in fp-home.ts; containers rely on it.""" + monkeypatch.setenv("FAILPROOFAI_HOME", str(tmp_path / "elsewhere")) + assert _resolver.get_base_dir() == tmp_path / "elsewhere" / "custom-agents" + + +def test_the_retired_opt_in_variable_no_longer_exists(): + """It was the entry point to the dead branch; leaving it would mislead. + + Anyone who exported it wanted the umbrella and now gets it by default, so + removing it changes no behaviour for them — only the docs they read. + """ + assert not hasattr(_resolver, "SPOOL_OPT_IN_ENV") + + +def test_the_retired_opt_in_variable_has_no_effect_if_someone_still_exports_it( + tmp_path, monkeypatch +): + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + monkeypatch.setenv("AGENTEYE_SPOOL_TO_FAILPROOFAI", "0") + assert _resolver.get_base_dir() == tmp_path / ".failproofai" / "custom-agents" + + +def test_the_legacy_root_is_still_spelled_somewhere_findable(tmp_path, monkeypatch): + """The migration notes and the escape hatch both name it; one definition.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + assert _resolver.legacy_agenteye_dir() == tmp_path / ".agenteye" diff --git a/sdk/python/tests/test_scopes.py b/sdk/python/tests/test_scopes.py new file mode 100644 index 000000000..49c05a58b --- /dev/null +++ b/sdk/python/tests/test_scopes.py @@ -0,0 +1,487 @@ +"""Tests for session() / agent() / tool_call(). + +The exception table in `_scopes.agent` is the contract most likely to be broken +by a well-meaning refactor, so every row of it has a test *and* every test +asserts event ORDER, not just presence: the dashboard closes the agent span at +`agent_end`, so an `error` emitted after it is attributed to nothing. +""" + +import asyncio +from datetime import datetime, timezone + +import pytest + +import failproofai_sdk +import failproofai_sdk._context as _context +import failproofai_sdk._runtime as _runtime + + +def indexes(entries, event_type): + return [i for i, e in enumerate(entries) if e["type"] == event_type] + + +def only(entries, event_type): + matches = [e for e in entries if e["type"] == event_type] + assert len(matches) == 1, f"expected one {event_type}, got {len(matches)}" + return matches[0] + + +# --------------------------------------------------------------------------- +# session() +# --------------------------------------------------------------------------- + +def test_session_emits_no_events(events): + with failproofai_sdk.session("s-1"): + pass + assert events.entries == [] + + +def test_session_binds_agent_id_without_emitting(events): + with failproofai_sdk.session("s-1", agent_id="worker"): + _runtime.event.agent_start() + entry = only(events.entries, "agent_start") + assert entry["session_id"] == "s-1" + assert entry["agent_id"] == "worker" + + +# --------------------------------------------------------------------------- +# agent() — happy path +# --------------------------------------------------------------------------- + +def test_agent_brackets_the_run(events): + with failproofai_sdk.agent("planner", session_id="s-1", goal="find it"): + pass + assert events.types() == ["agent_start", "agent_end"] + start, end = events.entries + assert start["agent_id"] == "planner" + assert start["goal"] == "find it" + assert "parent_id" not in start + assert end["outcome"] == "success" + + +def test_agent_generates_a_session_id(events): + with failproofai_sdk.agent("planner") as ident: + assert len(ident.session_id) == 32 + assert events.entries[0]["session_id"] == ident.session_id + + +def test_agent_custom_outcome_and_summary(events): + with failproofai_sdk.agent("a", session_id="s", outcome="partial", summary="did half"): + pass + end = only(events.entries, "agent_end") + assert end["outcome"] == "partial" + assert end["summary"] == "did half" + + +def test_agent_extra_fields_land_on_agent_start(events): + with failproofai_sdk.agent("a", session_id="s", framework="langgraph", fw_node="retrieve"): + pass + start = only(events.entries, "agent_start") + assert start["framework"] == "langgraph" + assert start["fw_node"] == "retrieve" + + +def test_agent_default_id_is_main(events): + with failproofai_sdk.agent(session_id="s"): + pass + assert events.entries[0]["agent_id"] == "main" + + +# --------------------------------------------------------------------------- +# Nesting and parent_id +# --------------------------------------------------------------------------- + +def test_parent_ids_follow_the_stack(events): + with failproofai_sdk.agent("a", session_id="s"): + with failproofai_sdk.agent("b"): + with failproofai_sdk.agent("c"): + assert failproofai_sdk.current().depth == 3 + assert failproofai_sdk.current().parent_id == "b" + + starts = [e for e in events.entries if e["type"] == "agent_start"] + assert [e["agent_id"] for e in starts] == ["a", "b", "c"] + assert "parent_id" not in starts[0] + assert starts[1]["parent_id"] == "a" + assert starts[2]["parent_id"] == "b" + + +def test_explicit_parent_none_forces_a_root_span(events): + with failproofai_sdk.agent("a", session_id="s"): + with failproofai_sdk.agent("b", parent_id=None): + pass + start_b = [e for e in events.entries if e["type"] == "agent_start"][1] + assert "parent_id" not in start_b + + +def test_explicit_parent_string_overrides(events): + with failproofai_sdk.agent("a", session_id="s"): + with failproofai_sdk.agent("b", parent_id="somewhere-else"): + pass + start_b = [e for e in events.entries if e["type"] == "agent_start"][1] + assert start_b["parent_id"] == "somewhere-else" + + +def test_nested_agent_inherits_the_session(events): + with failproofai_sdk.agent("a", session_id="s-outer"): + with failproofai_sdk.agent("b"): + pass + assert {e["session_id"] for e in events.entries} == {"s-outer"} + + +def test_stack_unwinds_after_an_exception_through_three_scopes(events): + class Boom(Exception): + pass + + try: + with failproofai_sdk.agent("a", session_id="s"): + with failproofai_sdk.agent("b"): + with failproofai_sdk.agent("c"): + raise Boom("deep") + except Boom as exc: + assert str(exc) == "deep" + else: + pytest.fail("Boom was not raised") + + assert failproofai_sdk.current().depth == 0 + assert _context.snapshot() == (None, ()) + # Each level reports the failure on its own span, innermost first. + assert events.types() == [ + "agent_start", "agent_start", "agent_start", + "error", "agent_end", + "error", "agent_end", + "error", "agent_end", + ] + ends = [e for e in events.entries if e["type"] == "agent_end"] + assert [e["agent_id"] for e in ends] == ["c", "b", "a"] + + +def test_stack_unwinds_even_when_emission_fails(events, monkeypatch): + real_agent_end = _runtime.event.agent_end + + def explode(**kwargs): + if kwargs.get("agent_id") == "b": + raise RuntimeError("writer is down") + return real_agent_end(**kwargs) + + monkeypatch.setattr(_runtime.event, "agent_end", explode) + + with failproofai_sdk.agent("a", session_id="s"): + with pytest.raises(RuntimeError, match="writer is down"): + with failproofai_sdk.agent("b"): + pass + assert failproofai_sdk.current().agent_id == "a" + assert failproofai_sdk.current().depth == 1 + + +def test_failed_agent_start_does_not_leave_a_frame(events): + with pytest.raises(ValueError, match="Reserved field"): + with failproofai_sdk.agent("a", session_id="s", timestamp="nope"): + pass + assert _context.snapshot() == (None, ()) + + +# --------------------------------------------------------------------------- +# The exception table — one test per row, asserting order +# --------------------------------------------------------------------------- + +def test_no_exception_emits_only_agent_end(events): + with failproofai_sdk.agent("a", session_id="s"): + pass + assert events.types() == ["agent_start", "agent_end"] + assert only(events.entries, "agent_end")["outcome"] == "success" + + +def test_exception_emits_error_before_agent_end(events): + try: + with failproofai_sdk.agent("a", session_id="s"): + raise ValueError("nope") + except ValueError as exc: + assert str(exc) == "nope" + else: + pytest.fail("ValueError was not raised") + + types = events.types() + assert types.index("error") < types.index("agent_end") + err = only(events.entries, "error") + assert err["error_type"] == "ValueError" + assert err["message"] == "nope" + assert "ValueError: nope" in err["traceback"] + end = only(events.entries, "agent_end") + # "failed", never "failure": only error|failed|timeout|rejected count as a + # failure server-side. + assert end["outcome"] == "failed" + + +def _assert_failed_base_exception(events, expected_type): + types = events.types() + assert types.index("error") < types.index("agent_end") + assert only(events.entries, "agent_end")["outcome"] == "failed" + assert only(events.entries, "error")["error_type"] == expected_type.__name__ + + +def test_keyboard_interrupt_is_reported_as_failed(events): + try: + with failproofai_sdk.agent("a", session_id="s"): + raise KeyboardInterrupt() + except KeyboardInterrupt: + pass + else: + pytest.fail("KeyboardInterrupt was not raised") + _assert_failed_base_exception(events, KeyboardInterrupt) + + +def test_system_exit_is_reported_as_failed(events): + try: + with failproofai_sdk.agent("a", session_id="s"): + raise SystemExit() + except SystemExit: + pass + else: + pytest.fail("SystemExit was not raised") + _assert_failed_base_exception(events, SystemExit) + + +def _assert_cancelled_without_error(events): + assert events.types() == ["agent_start", "agent_end"] + assert only(events.entries, "agent_end")["outcome"] == "cancelled" + + +def test_cancelled_error_is_not_an_error(events): + try: + with failproofai_sdk.agent("a", session_id="s"): + raise asyncio.CancelledError() + except asyncio.CancelledError: + pass + else: + pytest.fail("CancelledError was not raised") + _assert_cancelled_without_error(events) + + +def test_generator_exit_is_not_an_error(events): + try: + with failproofai_sdk.agent("a", session_id="s"): + raise GeneratorExit() + except GeneratorExit: + pass + else: + pytest.fail("GeneratorExit was not raised") + _assert_cancelled_without_error(events) + + +def test_real_task_cancellation_is_reported_as_cancelled(events): + async def main(): + started = asyncio.Event() + + async def body(): + async with failproofai_sdk.agent("a", session_id="s"): + started.set() + await asyncio.sleep(60) + + task = asyncio.create_task(body()) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + cancelled_result = await task + pytest.fail(f"cancelled task returned {cancelled_result!r}") + + asyncio.run(main()) + assert events.types() == ["agent_start", "agent_end"] + assert only(events.entries, "agent_end")["outcome"] == "cancelled" + + +# --------------------------------------------------------------------------- +# tool_call() +# --------------------------------------------------------------------------- + +def test_tool_call_pairs_and_carries_output(events): + with failproofai_sdk.agent("a", session_id="s"): + with failproofai_sdk.tool_call("web_search", input={"q": "x"}) as t: + t.output = {"hits": 3} + call_id = t.id + + assert events.types() == ["agent_start", "tool_use", "tool_result", "agent_end"] + use = only(events.entries, "tool_use") + res = only(events.entries, "tool_result") + assert use["tool_call_id"] == res["tool_call_id"] == call_id + assert use["input"] == {"q": "x"} + assert res["output"] == {"hits": 3} + assert "error" not in res + assert isinstance(res["duration_ms"], int) + + +def test_tool_call_id_is_read_only(): + box = failproofai_sdk._scopes.ToolCall("abc") + assert box.id == "abc" + with pytest.raises(AttributeError): + box.id = "other" + # __slots__: no stray attributes + with pytest.raises(AttributeError): + box.whatever = 1 + + +def test_tool_call_accepts_an_explicit_id(events): + with failproofai_sdk.agent("a", session_id="s"): + with failproofai_sdk.tool_call("t", tool_call_id="run-42") as t: + assert t.id == "run-42" + assert only(events.entries, "tool_use")["tool_call_id"] == "run-42" + + +def test_tool_call_failure_emits_tool_result_error_and_no_error_event(events): + with failproofai_sdk.agent("a", session_id="s"): + try: + with failproofai_sdk.tool_call("t"): + raise TypeError("bad arg") + except TypeError as exc: + assert str(exc) == "bad arg" + else: + pytest.fail("TypeError was not raised") + + # No `error` event from the tool: an exception the agent loop catches is + # not a run-level error. + assert events.types() == ["agent_start", "tool_use", "tool_result", "agent_end"] + assert only(events.entries, "tool_result")["error"] == "TypeError: bad arg" + assert only(events.entries, "agent_end")["outcome"] == "success" + + +def test_propagating_tool_failure_is_reported_exactly_once(events): + try: + with failproofai_sdk.agent("a", session_id="s"): + with failproofai_sdk.tool_call("t"): + raise TypeError("bad arg") + except TypeError as exc: + assert str(exc) == "bad arg" + else: + pytest.fail("TypeError was not raised") + + assert events.types() == [ + "agent_start", "tool_use", "tool_result", "error", "agent_end", + ] + assert len(indexes(events.entries, "error")) == 1 + types = events.types() + assert types.index("error") < types.index("agent_end") + + +def test_tool_call_uses_the_enclosing_agent_id(events): + with failproofai_sdk.agent("outer", session_id="s"): + with failproofai_sdk.agent("inner"): + with failproofai_sdk.tool_call("t"): + pass + use = only(events.entries, "tool_use") + res = only(events.entries, "tool_result") + assert use["agent_id"] == res["agent_id"] == "inner" + + +def test_tool_call_without_a_session_raises_naming_the_fix(events): + with pytest.raises(TypeError, match="propagate"): + with failproofai_sdk.tool_call("t"): + pass + + +def test_awkward_payload_does_not_break_the_scope(events): + """A non-JSON-serialisable output must not stop `agent_end` landing. + + Coercion happens in the writer (`json.dumps(default=str)`), so the scope + itself must pass the object through untouched and still close the span. + """ + value = datetime(2026, 7, 29, 12, 0, 0, tzinfo=timezone.utc) + with failproofai_sdk.agent("a", session_id="s"): + with failproofai_sdk.tool_call("t") as call: + call.output = value + + assert events.types() == ["agent_start", "tool_use", "tool_result", "agent_end"] + assert only(events.entries, "tool_result")["output"] is value + + +def test_awkward_payload_survives_the_real_writer(tmp_path): + """The end-to-end version: through the actual JSONL writer, on disk.""" + import json + + from failproofai_sdk._events import EventNamespace + from failproofai_sdk._writer import EventWriter + + writer = EventWriter(flush_interval=3600) + namespace = EventNamespace(writer) + original = _runtime.event + _runtime.event = namespace + try: + with failproofai_sdk.agent("a", session_id="s"): + with failproofai_sdk.tool_call("t") as call: + call.output = datetime(2026, 7, 29, 12, 0, 0, tzinfo=timezone.utc) + writer.flush_now() + finally: + _runtime.event = original + + path = next((tmp_path / "events").glob("*.jsonl")) + parsed = [json.loads(line) for line in path.read_text().splitlines()] + assert [e["type"] for e in parsed] == [ + "agent_start", "tool_use", "tool_result", "agent_end", + ] + assert parsed[2]["output"] == "2026-07-29 12:00:00+00:00" + + +# --------------------------------------------------------------------------- +# `async with` parity — the same body, both syntaxes, byte-identical dicts +# --------------------------------------------------------------------------- + +def _scrub(entries): + """Drop the only fields that legitimately differ between two runs.""" + return [{k: v for k, v in e.items() if k not in ("timestamp", "duration_ms")} for e in entries] + + +def _sync_body(): + with failproofai_sdk.agent("planner", session_id="s-parity", goal="g", fw_node="n"): + with failproofai_sdk.agent("worker"): + with failproofai_sdk.tool_call("search", tool_call_id="tc-1", input={"q": "x"}) as t: + t.output = ["a", "b"] + + +async def _async_body(): + async with failproofai_sdk.agent("planner", session_id="s-parity", goal="g", fw_node="n"): + async with failproofai_sdk.agent("worker"): + async with failproofai_sdk.tool_call("search", tool_call_id="tc-1", input={"q": "x"}) as t: + t.output = ["a", "b"] + + +def _sync_failing(): + with pytest.raises(ValueError): + with failproofai_sdk.agent("planner", session_id="s-parity"): + with failproofai_sdk.tool_call("search", tool_call_id="tc-1"): + raise ValueError("nope") + + +async def _async_failing(): + with pytest.raises(ValueError): + async with failproofai_sdk.agent("planner", session_id="s-parity"): + async with failproofai_sdk.tool_call("search", tool_call_id="tc-1"): + raise ValueError("nope") + + +@pytest.mark.parametrize( + "sync_body,async_body", + [(_sync_body, _async_body), (_sync_failing, _async_failing)], + ids=["happy", "failing"], +) +def test_async_with_is_identical_to_with(events, sync_body, async_body): + sync_body() + sync_entries = _scrub(events.entries) + events.entries.clear() + + asyncio.run(async_body()) + async_entries = _scrub(events.entries) + + # `traceback` embeds the frame that raised, which differs by construction. + for entry in sync_entries + async_entries: + entry.pop("traceback", None) + + assert sync_entries == async_entries + assert sync_entries # not vacuously equal + + +def test_async_session_binds_and_unbinds(events): + async def main(): + async with failproofai_sdk.session("s-async") as sid: + assert sid == "s-async" + assert failproofai_sdk.current().session_id == "s-async" + + asyncio.run(main()) + assert failproofai_sdk.current().session_id is None diff --git a/sdk/python/tests/test_sdk.py b/sdk/python/tests/test_sdk.py new file mode 100644 index 000000000..5f9c84ba7 --- /dev/null +++ b/sdk/python/tests/test_sdk.py @@ -0,0 +1,893 @@ +""" +Tests for the failproofai_sdk SDK. + +Unit tests use EventNamespace with a mock writer (no disk I/O). +Integration tests use configure() + real tmpdir + flush_now(). +""" + +import json +import time +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +import failproofai_sdk +from failproofai_sdk._events import EventNamespace +from failproofai_sdk._schema import ( + AgentEndEvent, + AgentPauseEvent, + AgentResumeEvent, + AgentStartEvent, + ErrorEvent, + HookCompletedEvent, + HookTriggeredEvent, + HumanInputEvent, + HumanInterruptEvent, + HumanPauseEvent, + HumanWaitEvent, + ModelRequestEvent, + ModelResponseEvent, + ToolResultEvent, + ToolUseEvent, +) +from failproofai_sdk._writer import EventWriter + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +class MockWriter: + """Collects submitted entries without touching disk.""" + + def __init__(self): + self.entries: list[dict] = [] + + def submit(self, entry: dict) -> None: + self.entries.append(entry) + + def last(self) -> dict: + return self.entries[-1] + + +@pytest.fixture() +def mock_writer(): + return MockWriter() + + +@pytest.fixture() +def ns(mock_writer): + return EventNamespace(mock_writer) + + +# --------------------------------------------------------------------------- +# Reserved field validation +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("reserved", ["timestamp", "type"]) +def test_reserved_fields_raise_via_extra(ns, reserved): + # timestamp and type aren't in the explicit signature so they land in **fields and hit our validator + with pytest.raises(ValueError, match="Reserved field"): + ns.agent_start(session_id="s1", agent_id="a1", **{reserved: "bad"}) + + +@pytest.mark.parametrize("reserved", ["session_id", "agent_id"]) +def test_reserved_fields_blocked_by_signature(ns, reserved): + # session_id and agent_id are explicit params; Python raises TypeError on duplicate keyword + with pytest.raises(TypeError): + ns.agent_start(session_id="s1", agent_id="a1", **{reserved: "bad"}) + + +# --------------------------------------------------------------------------- +# duration_ms rejection on paired end events +# --------------------------------------------------------------------------- + +def test_tool_result_rejects_duration_ms(ns): + with pytest.raises(ValueError, match="duration_ms"): + ns.tool_result(session_id="s1", agent_id="a1", tool_name="t", tool_call_id="tc1", duration_ms=99) + + +def test_hook_completed_rejects_duration_ms(ns): + with pytest.raises(ValueError, match="duration_ms"): + ns.hook_completed(session_id="s1", agent_id="a1", hook_name="h", hook_id="hid1", duration_ms=99) + + +# --------------------------------------------------------------------------- +# Null field omission +# --------------------------------------------------------------------------- + +def test_null_fields_omitted_tool_use(ns, mock_writer): + ns.tool_use(session_id="s1", agent_id="a1", tool_name="search", tool_call_id="tc1") + d = mock_writer.last() + assert "input" not in d + + +def test_null_fields_omitted_model_request(ns, mock_writer): + ns.model_request(session_id="s1", agent_id="a1") + d = mock_writer.last() + for f in ("model", "messages", "system", "tools"): + assert f not in d + + +def test_null_fields_omitted_model_response(ns, mock_writer): + ns.model_response(session_id="s1", agent_id="a1") + d = mock_writer.last() + for f in ("model", "stop_reason", "input_tokens", "output_tokens", "content", "role"): + assert f not in d + + +def test_model_request_fields_captured(ns, mock_writer): + messages = [{"role": "user", "content": "hello"}] + tools = [{"name": "search", "input_schema": {"type": "object"}}] + ns.model_request( + session_id="s1", agent_id="a1", model="claude-opus-4-6", + messages=messages, system="You are helpful.", tools=tools, + ) + d = mock_writer.last() + assert d["messages"] == messages + assert d["system"] == "You are helpful." + assert d["tools"] == tools + + +def test_model_response_content_as_string(ns, mock_writer): + ns.model_response( + session_id="s1", agent_id="a1", + content="plain text completion", role="assistant", + ) + d = mock_writer.last() + assert d["content"] == "plain text completion" + assert d["role"] == "assistant" + + +def test_model_response_content_as_blocks(ns, mock_writer): + blocks = [ + {"type": "text", "text": "I'll search..."}, + {"type": "tool_use", "id": "toolu_01", "name": "search", "input": {"q": "x"}}, + ] + ns.model_response(session_id="s1", agent_id="a1", content=blocks, role="assistant") + d = mock_writer.last() + assert d["content"] == blocks + assert d["role"] == "assistant" + + +def test_null_fields_omitted_agent_start(ns, mock_writer): + ns.agent_start(session_id="s1", agent_id="a1") + d = mock_writer.last() + assert "goal" not in d + assert "parent_id" not in d + + +def test_null_fields_omitted_error(ns, mock_writer): + ns.error(session_id="s1", agent_id="a1", error_type="ValueError", message="oops") + d = mock_writer.last() + assert "traceback" not in d + + +# --------------------------------------------------------------------------- +# Field ordering +# --------------------------------------------------------------------------- + +def test_field_ordering(ns, mock_writer): + ns.agent_start(session_id="s1", agent_id="a1", goal="do stuff") + d = mock_writer.last() + keys = list(d.keys()) + assert keys[0] == "timestamp" + assert keys[1] == "session_id" + assert keys[2] == "agent_id" + assert keys[3] == "type" + + +def test_custom_fields_at_end(ns, mock_writer): + ns.agent_end(session_id="s1", agent_id="a1", outcome="success", message="done", custom_key="val") + d = mock_writer.last() + keys = list(d.keys()) + assert keys.index("message") > keys.index("outcome") + assert keys.index("custom_key") > keys.index("outcome") + assert d["message"] == "done" + assert d["custom_key"] == "val" + + +# --------------------------------------------------------------------------- +# Auto duration computation +# --------------------------------------------------------------------------- + +def test_tool_use_result_duration(ns, mock_writer): + ns.tool_use(session_id="s1", agent_id="a1", tool_name="search", tool_call_id="tc1") + time.sleep(0.01) + ns.tool_result(session_id="s1", agent_id="a1", tool_name="search", tool_call_id="tc1", output="ok") + d = mock_writer.last() + assert "duration_ms" in d + assert d["duration_ms"] >= 10.0 # at least 10ms + + +def test_hook_triggered_completed_duration(ns, mock_writer): + ns.hook_triggered(session_id="s1", agent_id="a1", hook_name="pre_tool_use", hook_id="hid1") + time.sleep(0.01) + ns.hook_completed(session_id="s1", agent_id="a1", hook_name="pre_tool_use", hook_id="hid1", outcome="success") + d = mock_writer.last() + assert "duration_ms" in d + assert d["duration_ms"] >= 10.0 + + +# --------------------------------------------------------------------------- +# Missing correlation ID → duration_ms absent +# --------------------------------------------------------------------------- + +def test_tool_result_without_prior_tool_use(ns, mock_writer): + ns.tool_result(session_id="s1", agent_id="a1", tool_name="search", tool_call_id="no-prior") + d = mock_writer.last() + assert "duration_ms" not in d + + +def test_hook_completed_without_prior_triggered(ns, mock_writer): + ns.hook_completed(session_id="s1", agent_id="a1", hook_name="h", hook_id="no-prior", outcome="success") + d = mock_writer.last() + assert "duration_ms" not in d + + +# --------------------------------------------------------------------------- +# All 9 event types — type field smoke test +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("method,kwargs,expected_type", [ + ("tool_use", {"tool_name": "t", "tool_call_id": "c1"}, "tool_use"), + ("tool_result", {"tool_name": "t", "tool_call_id": "c2"}, "tool_result"), + ("model_request", {}, "model_request"), + ("model_response", {}, "model_response"), + ("agent_start", {}, "agent_start"), + ("agent_end", {}, "agent_end"), + ("hook_triggered", {"hook_name": "h", "hook_id": "hid"}, "hook_triggered"), + ("hook_completed", {"hook_name": "h", "hook_id": "hid2"}, "hook_completed"), + ("error", {"error_type": "ValueError", "message": "oops"}, "error"), + ("human_wait", {"input_id": "inp1"}, "human_wait"), + ("human_input", {"input_id": "inp2"}, "human_input"), + ("human_pause", {}, "human_pause"), + ("human_interrupt", {}, "human_interrupt"), + ("agent_pause", {"pause_id": "p1"}, "agent_pause"), + ("agent_resume", {"pause_id": "p2"}, "agent_resume"), +]) +def test_event_types(ns, mock_writer, method, kwargs, expected_type): + getattr(ns, method)(session_id="s1", agent_id="a1", **kwargs) + assert mock_writer.last()["type"] == expected_type + + +# --------------------------------------------------------------------------- +# Schema to_dict directly — field values +# --------------------------------------------------------------------------- + +def test_tool_use_schema(): + e = ToolUseEvent( + timestamp="2026-03-31T14:22:01.123456Z", + session_id="run-abc", + agent_id="planner", + tool_name="web_search", + tool_call_id="toolu_01", + input={"query": "test"}, + ) + d = e.to_dict() + assert d["type"] == "tool_use" + assert d["tool_name"] == "web_search" + assert d["input"] == {"query": "test"} + + +def test_tool_result_with_duration_schema(): + e = ToolResultEvent( + timestamp="2026-03-31T14:22:02.456789Z", + session_id="run-abc", + agent_id="planner", + tool_name="web_search", + tool_call_id="toolu_01", + output={"results": ["x"]}, + duration_ms=1333.6, + ) + d = e.to_dict() + assert d["duration_ms"] == 1333.6 + assert d["output"] == {"results": ["x"]} + assert "error" not in d + + +def test_error_schema(): + e = ErrorEvent( + timestamp="2026-03-31T14:22:08.500000Z", + session_id="run-abc", + agent_id="planner", + error_type="TimeoutError", + message="timed out", + traceback="Traceback...", + ) + d = e.to_dict() + assert d["error_type"] == "TimeoutError" + assert d["traceback"] == "Traceback..." + + +# --------------------------------------------------------------------------- +# Human-in-the-loop event tests +# --------------------------------------------------------------------------- + +def test_human_input_rejects_duration_ms(ns): + with pytest.raises(ValueError, match="duration_ms"): + ns.human_input(session_id="s1", agent_id="a1", input_id="inp1", duration_ms=99) + + +def test_human_wait_input_duration(ns, mock_writer): + ns.human_wait(session_id="s1", agent_id="a1", input_id="inp1") + time.sleep(0.01) + ns.human_input(session_id="s1", agent_id="a1", input_id="inp1", response="approved") + d = mock_writer.last() + assert "duration_ms" in d + assert d["duration_ms"] >= 10.0 + assert d["response"] == "approved" + + +def test_human_input_without_prior_wait(ns, mock_writer): + ns.human_input(session_id="s1", agent_id="a1", input_id="no-prior") + d = mock_writer.last() + assert "duration_ms" not in d + + +# --------------------------------------------------------------------------- +# Agent pause / resume event tests +# --------------------------------------------------------------------------- + +def test_agent_resume_rejects_duration_ms(ns): + with pytest.raises(ValueError, match="duration_ms"): + ns.agent_resume(session_id="s1", agent_id="a1", pause_id="p1", duration_ms=99) + + +def test_agent_pause_resume_duration(ns, mock_writer): + ns.agent_pause(session_id="s1", agent_id="a1", pause_id="p1", reason="waiting_for_user") + time.sleep(0.01) + ns.agent_resume(session_id="s1", agent_id="a1", pause_id="p1") + d = mock_writer.last() + assert d["type"] == "agent_resume" + assert d["pause_id"] == "p1" + assert "duration_ms" in d + assert d["duration_ms"] >= 10.0 + + +def test_agent_resume_without_prior_pause(ns, mock_writer): + ns.agent_resume(session_id="s1", agent_id="a1", pause_id="no-prior") + d = mock_writer.last() + assert d["type"] == "agent_resume" + assert d["pause_id"] == "no-prior" + assert "duration_ms" not in d + + +def test_agent_pause_fields(ns, mock_writer): + ns.agent_pause(session_id="s1", agent_id="a1", pause_id="p1", reason="user_requested", user_id="usr_9") + d = mock_writer.last() + assert d["type"] == "agent_pause" + assert d["pause_id"] == "p1" + assert d["reason"] == "user_requested" + assert d["user_id"] == "usr_9" + + +def test_agent_pause_null_fields_omitted(ns, mock_writer): + ns.agent_pause(session_id="s1", agent_id="a1", pause_id="p1") + d = mock_writer.last() + assert d["pause_id"] == "p1" + assert "reason" not in d + assert "user_id" not in d + + +def test_agent_pause_resume_cross_namespace_link(): + # Pause in one EventNamespace ("process"), resume in another: the in-memory + # _pending map does not carry over, so duration_ms is absent — but the + # shared pause_id still links the two events for downstream pairing. + w1, w2 = MockWriter(), MockWriter() + EventNamespace(w1).agent_pause(session_id="s1", agent_id="a1", pause_id="cross-1") + EventNamespace(w2).agent_resume(session_id="s1", agent_id="a1", pause_id="cross-1") + pause_d, resume_d = w1.last(), w2.last() + assert pause_d["type"] == "agent_pause" and pause_d["pause_id"] == "cross-1" + assert resume_d["type"] == "agent_resume" and resume_d["pause_id"] == "cross-1" + assert "duration_ms" not in resume_d # no in-process pending → no auto-duration + + +def test_agent_pause_id_isolated_from_tool_call_id(ns, mock_writer): + # A pause_id equal to a tool_call_id must NOT cross-link in the shared + # _pending map (pause keys are namespaced), else resume would steal the + # tool's start time and emit a bogus duration. + ns.tool_use(session_id="s1", agent_id="a1", tool_name="t", tool_call_id="x1") + ns.agent_resume(session_id="s1", agent_id="a1", pause_id="x1") + d = mock_writer.last() + assert d["type"] == "agent_resume" + assert "duration_ms" not in d + + +def test_agent_pause_resume_schema(): + p = AgentPauseEvent( + timestamp="2026-07-15T10:00:00.000000Z", session_id="s", agent_id="a", + pause_id="p1", reason="waiting_for_user", + ) + pd = p.to_dict() + assert pd["type"] == "agent_pause" + assert pd["pause_id"] == "p1" + assert pd["reason"] == "waiting_for_user" + assert "user_id" not in pd # None-dropped + + r = AgentResumeEvent( + timestamp="2026-07-15T10:05:00.000000Z", session_id="s", agent_id="a", + pause_id="p1", duration_ms=300000.0, + ) + rd = r.to_dict() + assert rd["type"] == "agent_resume" + assert rd["pause_id"] == "p1" + assert rd["duration_ms"] == 300000.0 + + +def test_human_wait_options_in_output(ns, mock_writer): + ns.human_wait( + session_id="s1", agent_id="a1", input_id="inp1", + prompt="Choose an action", options=["approve", "reject", "defer"], + ) + d = mock_writer.last() + assert d["options"] == ["approve", "reject", "defer"] + assert d["prompt"] == "Choose an action" + assert d["input_id"] == "inp1" + + +def test_human_wait_no_options_omitted(ns, mock_writer): + ns.human_wait(session_id="s1", agent_id="a1", input_id="inp1") + d = mock_writer.last() + assert "options" not in d + assert "prompt" not in d + assert "reason" not in d + + +def test_null_fields_omitted_human_pause(ns, mock_writer): + ns.human_pause(session_id="s1", agent_id="a1") + d = mock_writer.last() + assert "reason" not in d + assert "user_id" not in d + + +def test_null_fields_omitted_human_interrupt(ns, mock_writer): + ns.human_interrupt(session_id="s1", agent_id="a1") + d = mock_writer.last() + assert "reason" not in d + assert "user_id" not in d + assert "at_step" not in d + + +def test_human_pause_fields(ns, mock_writer): + ns.human_pause(session_id="s1", agent_id="a1", reason="user_requested", user_id="usr_42") + d = mock_writer.last() + assert d["type"] == "human_pause" + assert d["reason"] == "user_requested" + assert d["user_id"] == "usr_42" + + +def test_human_interrupt_fields(ns, mock_writer): + ns.human_interrupt( + session_id="s1", agent_id="a1", + reason="output_incorrect", user_id="usr_42", at_step="tool_use:web_search", + ) + d = mock_writer.last() + assert d["type"] == "human_interrupt" + assert d["reason"] == "output_incorrect" + assert d["at_step"] == "tool_use:web_search" + assert d["user_id"] == "usr_42" + + +def test_human_wait_schema(): + e = HumanWaitEvent( + timestamp="2026-05-11T10:00:00.000000Z", + session_id="run-abc", + agent_id="planner", + input_id="inp-01", + prompt="Do you approve?", + options=["approve", "reject"], + ) + d = e.to_dict() + assert d["type"] == "human_wait" + assert d["input_id"] == "inp-01" + assert d["prompt"] == "Do you approve?" + assert d["options"] == ["approve", "reject"] + assert "reason" not in d + + +def test_human_input_schema(): + e = HumanInputEvent( + timestamp="2026-05-11T10:00:05.000000Z", + session_id="run-abc", + agent_id="planner", + input_id="inp-01", + response="approve", + duration_ms=5000.0, + ) + d = e.to_dict() + assert d["type"] == "human_input" + assert d["input_id"] == "inp-01" + assert d["response"] == "approve" + assert d["duration_ms"] == 5000.0 + + +def test_human_wait_input_id_in_base_dict(ns, mock_writer): + # input_id must appear before environment in field ordering + ns.human_wait(session_id="s1", agent_id="a1", input_id="inp1") + keys = list(mock_writer.last().keys()) + assert keys.index("input_id") < keys.index("environment") + + +def test_human_input_input_id_in_base_dict(ns, mock_writer): + ns.human_input(session_id="s1", agent_id="a1", input_id="inp1") + keys = list(mock_writer.last().keys()) + assert keys.index("input_id") < keys.index("environment") + + +def test_human_wait_custom_fields(ns, mock_writer): + ns.human_wait(session_id="s1", agent_id="a1", input_id="inp1", tenant="acme") + d = mock_writer.last() + assert d["tenant"] == "acme" + + +def test_human_interrupt_pending_cleared_after_human_input(ns, mock_writer): + # Emit wait then input twice with same input_id — second input should have no duration + ns.human_wait(session_id="s1", agent_id="a1", input_id="inp1") + ns.human_input(session_id="s1", agent_id="a1", input_id="inp1") + ns.human_input(session_id="s1", agent_id="a1", input_id="inp1") + d = mock_writer.last() + assert "duration_ms" not in d + + +def test_environment_in_human_events(ns, mock_writer): + import failproofai_sdk._environment as env_mod + env_mod.set_environment("production") + ns.human_wait(session_id="s1", agent_id="a1", input_id="inp1") + ns.human_input(session_id="s1", agent_id="a1", input_id="inp1") + ns.human_pause(session_id="s1", agent_id="a1") + ns.human_interrupt(session_id="s1", agent_id="a1") + for entry in mock_writer.entries[-4:]: + assert entry["environment"] == "production" + + +def test_pending_dict_is_bounded(ns): + """Orphaned tool_use calls (no matching tool_result) must not grow + `_pending` without bound — at the cap, the oldest entry is evicted FIFO.""" + from failproofai_sdk._events import _PENDING_CAP, _tool_key + + for i in range(_PENDING_CAP + 50): + ns.tool_use( + session_id="s1", + agent_id="a1", + tool_name="t", + tool_call_id=f"call-{i}", + ) + assert len(ns._pending) == _PENDING_CAP + # Earliest IDs were evicted. + assert _tool_key("s1", "call-0") not in ns._pending + assert _tool_key("s1", "call-49") not in ns._pending + # Most-recent IDs are still tracked. + assert _tool_key("s1", f"call-{_PENDING_CAP + 49}") in ns._pending + + +# --------------------------------------------------------------------------- +# EventWriter — file writing integration tests +# --------------------------------------------------------------------------- + +def test_writer_creates_jsonl_file(tmp_path): + import failproofai_sdk._resolver as resolver + original = resolver._base_dir + try: + resolver.set_base_dir(tmp_path) + writer = EventWriter(flush_interval=60) # won't auto-flush during test + writer.submit({"timestamp": "t", "session_id": "s1", "agent_id": "a1", "type": "agent_start"}) + writer.flush_now() + + events_dir = tmp_path / "events" + jsonl_files = list(events_dir.glob("*.jsonl")) + assert len(jsonl_files) == 1 + + lines = jsonl_files[0].read_text().strip().splitlines() + assert len(lines) == 1 + parsed = json.loads(lines[0]) + assert parsed["type"] == "agent_start" + finally: + resolver.set_base_dir(original) + + +def test_writer_no_tmp_files_after_flush(tmp_path): + import failproofai_sdk._resolver as resolver + original = resolver._base_dir + try: + resolver.set_base_dir(tmp_path) + writer = EventWriter(flush_interval=60) + writer.submit({"timestamp": "t", "session_id": "s1", "agent_id": "a1", "type": "tool_use"}) + writer.flush_now() + + events_dir = tmp_path / "events" + tmp_files = list(events_dir.glob("*.tmp")) + assert tmp_files == [] + finally: + resolver.set_base_dir(original) + + +def test_writer_multiple_events_in_one_file(tmp_path): + import failproofai_sdk._resolver as resolver + original = resolver._base_dir + try: + resolver.set_base_dir(tmp_path) + writer = EventWriter(flush_interval=60) + for i in range(5): + writer.submit({"timestamp": "t", "session_id": "s1", "agent_id": "a1", "type": "model_request", "i": i}) + writer.flush_now() + + events_dir = tmp_path / "events" + jsonl_files = list(events_dir.glob("*.jsonl")) + assert len(jsonl_files) == 1 + lines = jsonl_files[0].read_text().strip().splitlines() + assert len(lines) == 5 + finally: + resolver.set_base_dir(original) + + +def test_writer_coerces_unserializable_payload_values(tmp_path): + import failproofai_sdk._resolver as resolver + original = resolver._base_dir + try: + resolver.set_base_dir(tmp_path) + writer = EventWriter(flush_interval=60) + value = datetime(2026, 7, 17, 12, 34, 56, tzinfo=timezone.utc) + writer.submit({"timestamp": "t", "session_id": "s1", "agent_id": "a1", "type": "tool_result", "output": value}) + writer.flush_now() + + path = next((tmp_path / "events").glob("*.jsonl")) + assert json.loads(path.read_text())["output"] == str(value) + assert writer._thread.is_alive() + finally: + resolver.set_base_dir(original) + + +def test_writer_requeues_batch_after_write_failure(monkeypatch): + writer = EventWriter(flush_interval=60) + entries = [{"i": 1}, {"i": 2}] + for entry in entries: + writer.submit(entry) + + monkeypatch.setattr(writer, "_write_batch", lambda _entries: (_ for _ in ()).throw(OSError("disk full"))) + with pytest.raises(OSError, match="disk full"): + writer.flush_now() + assert list(writer._queue) == entries + + +def test_writer_no_flush_when_empty(tmp_path): + import failproofai_sdk._resolver as resolver + original = resolver._base_dir + try: + resolver.set_base_dir(tmp_path) + writer = EventWriter(flush_interval=60) + writer.flush_now() # nothing in queue + + events_dir = tmp_path / "events" + assert not events_dir.exists() or list(events_dir.glob("*")) == [] + finally: + resolver.set_base_dir(original) + + +# --------------------------------------------------------------------------- +# configure() integration +# --------------------------------------------------------------------------- + +def test_configure_custom_base_dir(tmp_path): + failproofai_sdk.configure(base_dir=tmp_path, flush_interval=60) + failproofai_sdk.event.agent_start(session_id="s1", agent_id="a1", goal="test configure") + failproofai_sdk._writer.flush_now() + + events_dir = tmp_path / "events" + jsonl_files = list(events_dir.glob("*.jsonl")) + assert len(jsonl_files) == 1 + parsed = json.loads(jsonl_files[0].read_text().strip().splitlines()[0]) + assert parsed["goal"] == "test configure" + + +# --------------------------------------------------------------------------- +# atexit flush — events must survive process exit without explicit flush_now() +# --------------------------------------------------------------------------- + +def test_atexit_flushes_on_process_exit(tmp_path): + import subprocess + import sys + script = f""" +import failproofai_sdk +import failproofai_sdk._resolver as resolver +from pathlib import Path +resolver.set_base_dir(Path(r'{tmp_path}')) +failproofai_sdk.event.agent_start(session_id='s1', agent_id='a1') +# intentionally no flush_now() — atexit must handle it +""" + result = subprocess.run([sys.executable, "-c", script], capture_output=True) + assert result.returncode == 0, result.stderr.decode() + events_dir = tmp_path / "events" + jsonl_files = list(events_dir.glob("*.jsonl")) + assert len(jsonl_files) == 1 + parsed = json.loads(jsonl_files[0].read_text().strip().splitlines()[0]) + assert parsed["type"] == "agent_start" + + +# --------------------------------------------------------------------------- +# Timestamp format +# --------------------------------------------------------------------------- + +def test_timestamp_format(ns, mock_writer): + ns.agent_start(session_id="s1", agent_id="a1") + d = mock_writer.last() + ts = d["timestamp"] + # ISO 8601 with microsecond precision, UTC: 2026-04-01T12:34:56.789012Z + assert ts.endswith("Z") + assert "T" in ts + # Must be parseable + from datetime import datetime, timezone + dt = datetime.fromisoformat(ts.rstrip("Z") + "+00:00") + assert dt.tzinfo is not None + + +# --------------------------------------------------------------------------- +# Base dir resolution — set_base_dir() > $AGENTEYE_HOME > ~/.agenteye +# --------------------------------------------------------------------------- + +def test_base_dir_from_env_var(monkeypatch, tmp_path): + import failproofai_sdk._resolver as resolver + monkeypatch.setattr(resolver, "_base_dir", None) + monkeypatch.setenv("AGENTEYE_HOME", str(tmp_path)) + assert resolver.get_base_dir() == tmp_path + + +def test_set_base_dir_overrides_env_var(monkeypatch, tmp_path): + import failproofai_sdk._resolver as resolver + other = tmp_path / "explicit" + monkeypatch.setattr(resolver, "_base_dir", None) + monkeypatch.setenv("AGENTEYE_HOME", str(tmp_path / "from-env")) + resolver.set_base_dir(other) + try: + assert resolver.get_base_dir() == other + finally: + resolver.set_base_dir(None) + + +def test_default_when_neither_set(monkeypatch): + import failproofai_sdk._resolver as resolver + monkeypatch.setattr(resolver, "_base_dir", None) + monkeypatch.delenv("AGENTEYE_HOME", raising=False) + monkeypatch.delenv("FAILPROOFAI_HOME", raising=False) + assert resolver.get_base_dir() == Path.home() / ".failproofai" / "custom-agents" + + +def test_writer_uses_env_var_base_dir(monkeypatch, tmp_path): + """End-to-end: SDK writer respects AGENTEYE_HOME for events/ path.""" + import failproofai_sdk._resolver as resolver + monkeypatch.setattr(resolver, "_base_dir", None) + monkeypatch.setenv("AGENTEYE_HOME", str(tmp_path)) + writer = EventWriter(flush_interval=60) + writer.submit({"timestamp": "t", "session_id": "s1", "agent_id": "a1", "type": "agent_start"}) + writer.flush_now() + + jsonl_files = list((tmp_path / "events").glob("*.jsonl")) + assert len(jsonl_files) == 1 + + +# --------------------------------------------------------------------------- +# Environment +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def _reset_environment(): + """Reset the global environment state between every test.""" + import failproofai_sdk._environment as env_mod + original = env_mod._environment + yield + env_mod._environment = original + + +def test_environment_defaults_to_dev(ns, mock_writer): + import failproofai_sdk._environment as env_mod + env_mod._environment = None + ns.agent_start(session_id="s1", agent_id="a1") + assert mock_writer.last()["environment"] == "dev" + + +def test_environment_from_configure(tmp_path, mock_writer): + import failproofai_sdk._environment as env_mod + env_mod.set_environment("staging") + ns = EventNamespace(mock_writer) + ns.agent_start(session_id="s1", agent_id="a1") + assert mock_writer.last()["environment"] == "staging" + + +def test_environment_from_env_var(ns, mock_writer, monkeypatch): + import failproofai_sdk._environment as env_mod + env_mod._environment = None + monkeypatch.setenv("AGENTEYE_ENVIRONMENT", "production") + ns.agent_start(session_id="s1", agent_id="a1") + assert mock_writer.last()["environment"] == "production" + + +def test_configure_overrides_env_var(ns, mock_writer, monkeypatch): + import failproofai_sdk._environment as env_mod + monkeypatch.setenv("AGENTEYE_ENVIRONMENT", "production") + env_mod.set_environment("staging") + ns.agent_start(session_id="s1", agent_id="a1") + assert mock_writer.last()["environment"] == "staging" + + +def test_a_comma_in_configure_environment_is_refused_rather_than_dropped( + ns, mock_writer +): + # Ingest splits `environment` on commas to build its facets, so a line whose + # environment contains one is discarded WHOLE: 200 with + # `{"accepted":0,"skipped":N}`, the daemon deletes the delivered batch, and + # the run is simply never in the dashboard. Measured against the running + # stack before this check existed. `failproofaid` already refuses a comma in + # `collector.environment`; the SDK writes the same field and did not. + import failproofai_sdk._environment as env_mod + + with pytest.raises(ValueError, match="comma"): + env_mod.set_environment("prod,eu") + # And the rejected call must not have taken effect. + env_mod._environment = None + ns.agent_start(session_id="s1", agent_id="a1") + assert "," not in mock_writer.last()["environment"] + + +def test_a_comma_in_the_env_var_warns_and_falls_back_instead_of_raising( + ns, mock_writer, monkeypatch, caplog +): + # An env var is read lazily, inside `to_dict()` on whatever event happens to + # be next — raising there would take the caller's agent down from a line of + # telemetry, which a library in someone else's process must not do. Warn, + # and land the events under a visibly wrong environment rather than nowhere. + import logging + + import failproofai_sdk._environment as env_mod + + env_mod._environment = None + monkeypatch.setenv("AGENTEYE_ENVIRONMENT", "prod,eu") + with caplog.at_level(logging.WARNING, logger="failproofai_sdk"): + ns.agent_start(session_id="s1", agent_id="a1") + assert mock_writer.last()["environment"] == "dev" + assert "comma" in caplog.text + + +def test_environment_is_reserved(ns): + with pytest.raises(ValueError, match="Reserved field"): + ns.agent_start(session_id="s1", agent_id="a1", environment="bad") + + +def test_environment_field_ordering(ns, mock_writer): + import failproofai_sdk._environment as env_mod + env_mod.set_environment("qa") + ns.agent_start(session_id="s1", agent_id="a1", goal="test") + keys = list(mock_writer.last().keys()) + assert keys.index("environment") > keys.index("type") + assert keys.index("environment") < keys.index("goal") + + +def test_environment_in_all_event_types(ns, mock_writer): + import failproofai_sdk._environment as env_mod + env_mod.set_environment("canary") + ns.agent_start(session_id="s1", agent_id="a1") + ns.tool_use(session_id="s1", agent_id="a1", tool_name="t", tool_call_id="tc1") + ns.error(session_id="s1", agent_id="a1", error_type="E", message="m") + for entry in mock_writer.entries: + assert entry["environment"] == "canary" + + +def test_environment_integration(tmp_path): + import failproofai_sdk._resolver as resolver + import failproofai_sdk._environment as env_mod + original_dir = resolver._base_dir + try: + resolver.set_base_dir(tmp_path) + env_mod.set_environment("integration-test") + writer = EventWriter(flush_interval=60) + ns = EventNamespace(writer) + ns.agent_start(session_id="s1", agent_id="a1") + writer.flush_now() + + events_dir = tmp_path / "events" + jsonl_files = list(events_dir.glob("*.jsonl")) + assert len(jsonl_files) == 1 + parsed = json.loads(jsonl_files[0].read_text().strip().splitlines()[0]) + assert parsed["environment"] == "integration-test" + finally: + resolver.set_base_dir(original_dir) diff --git a/sdk/python/tests/test_server_contract.py b/sdk/python/tests/test_server_contract.py new file mode 100644 index 000000000..64f49abc8 --- /dev/null +++ b/sdk/python/tests/test_server_contract.py @@ -0,0 +1,643 @@ +"""What the ingest server promotes, and what this SDK must therefore emit. + +The server pulls a fixed set of keys out of each event's payload and stores them +as real indexed columns; everything else stays in the opaque `payload` blob. +Those promoted columns are what every filter, facet and chart in the product +reads. The extraction is total in the worst way: + + fn ps(payload, key) -> Option<&str> { payload.get(key).and_then(|v| v.as_str()) } + +A missing key and a key holding the wrong JSON type are indistinguishable — both +yield `None`, both store NULL, and the row still ingests with `200 OK`. So an +event that spells `tool_name` as `toolName`, or sends it as a number, lands in +storage looking successful and is invisible to every tool-name filter forever. + +This file pins the SDK's half. `PROMOTED` is frozen here because this repository +cannot see the server, and `test_promoted_columns_match_the_server` re-derives +the same list from a real AgentEye checkout when `FP_AGENTEYE_ROOT` points at +one, so the frozen copy cannot quietly rot. +""" +import json +import os +import re +from pathlib import Path + +import pytest + +from failproofai_sdk import _context, _events, _resolver, _schema +from failproofai_sdk._events import EventNamespace + +# ───────────────────────────────────────────────────────────────────────────── +# The promoted-column contract +# ───────────────────────────────────────────────────────────────────────────── + +#: Payload keys the server lifts into an indexed column via `ps()` (string). +PROMOTED_STRING = frozenset( + { + "tool_name", + "tool_call_id", + "hook_name", + "hook_id", + "input_id", + "pause_id", + "error_type", + "model", + } +) + +#: Payload keys the server lifts via `pu32()` (unsigned, 32-bit). +PROMOTED_NUMERIC = frozenset({"duration_ms", "input_tokens", "output_tokens"}) + +PROMOTED = PROMOTED_STRING | PROMOTED_NUMERIC + + +class _Recorder: + """Stands in for the writer so a call can be inspected without touching disk.""" + + def __init__(self): + self.entries = [] + + def submit(self, entry): + self.entries.append(entry) + + +def _emit_everything(): + """Call every public event method once, fully populated. Returns the payloads.""" + recorder = _Recorder() + ns = EventNamespace(recorder) + + ids = dict(session_id="s", agent_id="a") + ns.agent_start(**ids, goal="g", parent_id="p") + ns.agent_end(**ids, outcome="success", summary="s") + ns.agent_pause(**ids, pause_id="p1", reason="r", user_id="u") + ns.agent_resume(**ids, pause_id="p1", reason="r", user_id="u") + ns.tool_use(**ids, tool_name="bash", tool_call_id="tc", input={"cmd": "ls"}) + ns.tool_result(**ids, tool_name="bash", tool_call_id="tc", output="o") + ns.model_request(**ids, model="m", messages=[], system=None, tools=[]) + ns.model_response(**ids, model="m", stop_reason="end_turn", input_tokens=1, output_tokens=2, content="c", role="assistant") + ns.error(**ids, error_type="ValueError", message="m", traceback="t") + ns.hook_triggered(**ids, hook_name="h", hook_id="h1", trigger_event="PreToolUse", input={}) + ns.hook_completed(**ids, hook_name="h", hook_id="h1", outcome="allow", output="o") + ns.human_wait(**ids, input_id="i1", prompt="p", options=["y"], reason="r") + ns.human_input(**ids, input_id="i1", response="y") + ns.human_pause(**ids, reason="r", user_id="u") + ns.human_interrupt(**ids, reason="r", user_id="u", at_step="1") + return recorder.entries + + +def test_every_promoted_column_is_emitted_by_at_least_one_event(): + """A promoted column nothing ever populates is a dead column, silently.""" + emitted = set() + for payload in _emit_everything(): + emitted |= payload.keys() + + missing = PROMOTED - emitted + assert not missing, ( + f"no event emits {sorted(missing)}, so those indexed columns are " + "always NULL. Either an event method stopped sending the key, or the " + "server promotes something this SDK never produces." + ) + + +def test_promoted_values_have_the_json_type_the_extractor_accepts(): + """`ps()` needs a JSON string; `pu32()` needs a non-negative integer. + + A type mismatch is NOT an error on either side — it is a NULL column next to + a payload that still visibly contains the value, which is the single most + confusing shape this pipeline can produce. + """ + for payload in _emit_everything(): + for key in PROMOTED_STRING & payload.keys(): + assert isinstance(payload[key], str), ( + f"{payload['type']}.{key} is {type(payload[key]).__name__}; " + "ps() only reads JSON strings and would store NULL" + ) + for key in PROMOTED_NUMERIC & payload.keys(): + value = payload[key] + assert isinstance(value, int) and not isinstance(value, bool), ( + f"{payload['type']}.{key} is {type(value).__name__}; emit a " + "whole integer so the value survives regardless of how the " + "server's numeric extractor is implemented" + ) + assert value >= 0, f"{payload['type']}.{key} is negative; the column is unsigned" + assert value <= 0xFFFFFFFF, f"{payload['type']}.{key} overflows UInt32" + + +def test_promoted_keys_never_collide_with_a_reserved_name(): + """Reserved names are validated separately; a promoted key must not be one.""" + assert not (PROMOTED & _events._RESERVED) + + +@pytest.mark.parametrize("event_type", ["tool_result", "hook_completed", "human_input", "agent_resume"]) +def test_unpaired_events_omit_duration_rather_than_sending_zero(event_type): + """No start seen means unknown, and unknown must not be recorded as `0ms`. + + Zero is a real, plottable duration. Emitting it for "we never saw the start" + poisons every latency percentile with values that were never measured. + """ + recorder = _Recorder() + ns = EventNamespace(recorder) + ids = dict(session_id="s", agent_id="a") + + if event_type == "tool_result": + ns.tool_result(**ids, tool_name="t", tool_call_id="never-started") + elif event_type == "hook_completed": + ns.hook_completed(**ids, hook_name="h", hook_id="never-started") + elif event_type == "human_input": + ns.human_input(**ids, input_id="never-started") + else: + ns.agent_resume(**ids, pause_id="never-started") + + assert "duration_ms" not in recorder.entries[0] + + +# ───────────────────────────────────────────────────────────────────────────── +# Reserved names — enforcement is SPLIT across two mechanisms, deliberately +# ───────────────────────────────────────────────────────────────────────────── + + +def test_reserved_set_is_exactly_the_five_ingest_reads_structurally(): + assert _events._RESERVED == {"timestamp", "session_id", "agent_id", "type", "environment"} + + +@pytest.mark.parametrize("name", ["session_id", "agent_id"]) +def test_identity_kwargs_raise_type_error_not_value_error(name): + """These are explicit parameters, so Python rejects the duplicate first. + + Two exception types for one apparent rule. Documented rather than unified, + because unifying it means shadowing a real signature parameter to raise a + nicer error, and that trades a loud failure for a subtler one. + """ + ns = EventNamespace(_Recorder()) + kwargs = {"session_id": "s", "agent_id": "a"} + kwargs[name] = "duplicate" + with pytest.raises(TypeError): + ns.agent_start(**kwargs, **{name: "again"}) + + +@pytest.mark.parametrize("name", ["timestamp", "type", "environment"]) +def test_stamped_fields_raise_value_error_from_the_validator(name): + """These are not signature parameters, so they reach `_validate_fields`.""" + ns = EventNamespace(_Recorder()) + with pytest.raises(ValueError, match="Reserved field names"): + ns.agent_start(session_id="s", agent_id="a", **{name: "x"}) + + +@pytest.mark.parametrize( + "call", + [ + lambda ns: ns.tool_result(session_id="s", agent_id="a", tool_name="t", tool_call_id="c", duration_ms=5), + lambda ns: ns.hook_completed(session_id="s", agent_id="a", hook_name="h", hook_id="i", duration_ms=5), + lambda ns: ns.human_input(session_id="s", agent_id="a", input_id="i", duration_ms=5), + lambda ns: ns.agent_resume(session_id="s", agent_id="a", pause_id="p", duration_ms=5), + ], +) +def test_duration_ms_cannot_be_supplied_by_the_caller(call): + """It is measured, not reported — a caller-supplied value would be unfalsifiable.""" + ns = EventNamespace(_Recorder()) + with pytest.raises(ValueError, match="auto-computed"): + call(ns) + + +def test_a_reserved_name_is_rejected_by_every_event_method(): + """One method forgetting `_validate_fields` would let a caller rewrite `type`.""" + ns = EventNamespace(_Recorder()) + methods = [m for m in dir(ns) if not m.startswith("_") and callable(getattr(ns, m))] + assert len(methods) == 15, f"expected 15 event methods, found {len(methods)}: {sorted(methods)}" + + required = { + "tool_use": dict(tool_name="t", tool_call_id="c"), + "tool_result": dict(tool_name="t", tool_call_id="c"), + "hook_triggered": dict(hook_name="h", hook_id="i"), + "hook_completed": dict(hook_name="h", hook_id="i"), + "error": dict(error_type="E", message="m"), + "human_wait": dict(input_id="i"), + "human_input": dict(input_id="i"), + "agent_pause": dict(pause_id="p"), + "agent_resume": dict(pause_id="p"), + } + for method in methods: + with pytest.raises(ValueError, match="Reserved field names"): + getattr(ns, method)( + session_id="s", agent_id="a", **required.get(method, {}), type="hijacked" + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# The strings the rename must never touch +# ───────────────────────────────────────────────────────────────────────────── + +#: Renaming any of these desynchronises the SDK from the daemon that reads its +#: spool, with no error on either side — events are simply written somewhere +#: nothing watches. The Python import name and the PyPI distribution name were +#: renamed to failproofai_sdk / failproofai-sdk; the wire and filesystem +#: contract deliberately was not, exactly as the `fp` CLI kept `X-AgentEye-Org` +#: and the `ae_session` cookie through its own rename. +FROZEN_STRINGS = { + "AGENTEYE_HOME": ( + "the operator override both daemons honour, and the documented escape " + "hatch for a host still running agenteye-collector" + ), + "AGENTEYE_ENVIRONMENT": "the environment label, read at import time", + "FAILPROOFAI_HOME": "moves the umbrella root, mirrored in fp-home.ts", + "custom-agents": "the DEFAULT spool root, mirrored in fp-home.ts and config.rs", +} + +#: Deliberately NOT frozen, and each for its own reason. +#: +#: ``AGENTEYE_SPOOL_TO_FAILPROOFAI`` was the opt-in that selected the umbrella +#: root. It also required that directory to already exist, and nothing ever +#: created it, so the branch never once fired. It is retired rather than frozen: +#: the umbrella is the default now, so anyone who exported it already has what +#: they were asking for. +#: +#: ``.agenteye`` is no longer a literal this package must contain. It survives +#: in prose and in `legacy_agenteye_dir()`, but freezing it would make this test +#: pass on a comment — which is exactly how it passed while the variable above +#: was being deleted. +RETIRED_STRINGS = ("AGENTEYE_SPOOL_TO_FAILPROOFAI",) + +PACKAGE_DIR = Path(_resolver.__file__).resolve().parent + + +def test_the_spool_contract_strings_are_still_spelled_the_old_way(): + """A well-meaning rename sweep is the realistic threat here, not a redesign.""" + source = "\n".join( + p.read_text(encoding="utf-8") for p in sorted(PACKAGE_DIR.glob("*.py")) + ) + for literal, why in FROZEN_STRINGS.items(): + assert literal in source, ( + f"{literal!r} is gone from the package — {why}. This is a wire and " + "filesystem contract with a daemon that is released separately, so " + "renaming it here strands every event this SDK writes. If the " + "rename is genuinely intended, change the daemon FIRST and keep " + "reading the old name for at least one release." + ) + + +def test_the_retired_opt_in_is_not_read_by_any_module(): + """Freed, not merely unused — a leftover branch would contradict the docs. + + Checked over `os.environ` lookups rather than the raw text, because the name + still appears in `_resolver`'s prose explaining why it went away, and a + substring check over source is how the frozen-strings test above was passing + for the wrong reason while the variable was being deleted. + """ + import re + + for path in sorted(PACKAGE_DIR.glob("*.py")): + source = path.read_text(encoding="utf-8") + for retired in RETIRED_STRINGS: + reads = re.findall(rf"environ(?:\.get)?[\(\[]\s*[\"']{retired}", source) + assert not reads, f"{path.name} still reads the retired {retired}" + + +def test_the_spool_layout_is_events_and_failed_under_the_base_dir(): + """The daemon watches `<base>/events` and quarantines to `<base>/failed`.""" + writer_source = (PACKAGE_DIR / "_writer.py").read_text(encoding="utf-8") + assert '"events"' in writer_source + + +def test_batches_are_published_by_atomic_rename_from_a_tmp_suffix(): + """`.tmp` -> `.jsonl` via os.replace is what stops a half-written read. + + The daemon takes any `.jsonl` in the directory as complete. Writing straight + to the final name would hand it a truncated file whose unparseable lines + ingest counts as `skipped` — 200 OK, events gone. + """ + writer_source = (PACKAGE_DIR / "_writer.py").read_text(encoding="utf-8") + assert '.tmp' in writer_source + assert '.jsonl' in writer_source + assert "os.replace(tmp_path, final_path)" in writer_source, ( + "the atomic publish is gone. shutil.move, Path.rename across devices, or " + "a plain write to the final name all reintroduce the torn-read window." + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Cross-check against a real AgentEye checkout, when one is available +# ───────────────────────────────────────────────────────────────────────────── + +_AGENTEYE_ROOT = os.environ.get("FP_AGENTEYE_ROOT") +_INGEST_RS = Path(_AGENTEYE_ROOT) / "server" / "src" / "routes" / "ingest.rs" if _AGENTEYE_ROOT else None + +requires_agenteye_checkout = pytest.mark.skipif( + _INGEST_RS is None or not _INGEST_RS.is_file(), + reason="set FP_AGENTEYE_ROOT to an AgentEye checkout to verify against real ingest.rs", +) + + +@requires_agenteye_checkout +def test_promoted_columns_match_the_server(): + """Re-derive PROMOTED from ingest.rs so the frozen copy above cannot rot.""" + source = _INGEST_RS.read_text(encoding="utf-8") + + actual_strings = set(re.findall(r'ps\(payload_value,\s*"([a-z_]+)"\)', source)) + actual_numeric = set(re.findall(r'pu32\(payload_value,\s*"([a-z_]+)"\)', source)) + + # `model_name` is a documented fallback spelling the server also accepts; + # this SDK only ever emits `model`, which is the preferred one. + actual_strings.discard("model_name") + + assert actual_strings == PROMOTED_STRING, ( + "the server's promoted string columns drifted from this SDK's frozen " + f"copy. server={sorted(actual_strings)} sdk={sorted(PROMOTED_STRING)}" + ) + assert PROMOTED_NUMERIC >= actual_numeric, ( + "the server promotes a numeric column this SDK does not know about: " + f"{sorted(actual_numeric - PROMOTED_NUMERIC)}" + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Promoted numeric columns — the type has to be right, not just the key +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("name", sorted(_events._PROMOTED_NUMERIC)) +@pytest.mark.parametrize("bad", ["many", 12.5, True, -1, 2**32, [1], {"n": 1}]) +def test_a_promoted_numeric_is_refused_rather_than_stored_as_null(name, bad): + """`pu32()` returns None on a mismatch, and None is written as NULL at 200 OK. + + Nothing is logged and nothing is rejected — the row still arrives — so the + only symptom is a column that is populated for some events and not others. + `_RESERVED` never covered this: it blocks five structural keys and lets every + other custom field through untouched, whatever type it carries. + + Rejecting rather than coercing, because a float is a mistake worth hearing + about: the server drops it whole rather than rounding it, and rounding it + here would hide that from the one person able to fix the source. + """ + ns = EventNamespace(_Recorder()) + with pytest.raises(ValueError, match=name): + ns.agent_start(session_id="s", agent_id="a", **{name: bad}) + + +@pytest.mark.parametrize("name", ["input_tokens", "output_tokens"]) +def test_model_response_checks_its_own_two_token_counts(name): + """They are named parameters, so they never reach `_validate_fields`. + + They are also the likeliest of the three to arrive wrong — a caller reading + them straight off a provider's usage object gets whatever that object holds. + """ + ns = EventNamespace(_Recorder()) + with pytest.raises(ValueError, match=name): + ns.model_response(session_id="s", agent_id="a", model="m", **{name: "1024"}) + + +@pytest.mark.parametrize("name", sorted(_events._PROMOTED_NUMERIC)) +def test_a_valid_promoted_numeric_still_goes_through_untouched(name): + recorder = _Recorder() + EventNamespace(recorder).agent_start(session_id="s", agent_id="a", **{name: 4096}) + assert recorder.entries[0][name] == 4096 + + +@pytest.mark.parametrize("name", sorted(_events._PROMOTED_NUMERIC)) +def test_the_boundary_values_of_a_u32_are_accepted(name): + """0 and 2**32 - 1 are valid, and an off-by-one here silently rejects real data.""" + recorder = _Recorder() + ns = EventNamespace(recorder) + ns.agent_start(session_id="s", agent_id="a", **{name: 0}) + ns.agent_start(session_id="s", agent_id="a", **{name: 2**32 - 1}) + assert [e[name] for e in recorder.entries] == [0, 2**32 - 1] + + +def test_none_is_not_treated_as_a_bad_promoted_numeric(): + """`model_response` passes its optionals straight through as None.""" + recorder = _Recorder() + EventNamespace(recorder).model_response( + session_id="s", agent_id="a", model="m", input_tokens=None, output_tokens=None + ) + assert "input_tokens" not in recorder.entries[0] + + +def test_the_promoted_numeric_set_matches_the_one_ingest_lifts(): + """Two hand-maintained lists, one in the SDK and one in this file's header. + + If they drift, the validator stops covering a column that ingest still + promotes, and this file's own assertions stop describing the server. + """ + assert _events._PROMOTED_NUMERIC == PROMOTED_NUMERIC + + +# ───────────────────────────────────────────────────────────────────────────── +# The MEASURED duration is bound by the same u32 range a caller is held to +# ───────────────────────────────────────────────────────────────────────────── + +#: The four events that pair with an earlier one and compute `duration_ms`. +#: Each takes a start call and an end call against the same id. +PAIRED = { + "tool_result": ( + lambda ns: ns.tool_use(session_id="s", agent_id="a", tool_name="t", tool_call_id="x"), + lambda ns: ns.tool_result(session_id="s", agent_id="a", tool_name="t", tool_call_id="x"), + ), + "hook_completed": ( + lambda ns: ns.hook_triggered(session_id="s", agent_id="a", hook_name="h", hook_id="x"), + lambda ns: ns.hook_completed(session_id="s", agent_id="a", hook_name="h", hook_id="x"), + ), + "human_input": ( + lambda ns: ns.human_wait(session_id="s", agent_id="a", input_id="x"), + lambda ns: ns.human_input(session_id="s", agent_id="a", input_id="x"), + ), + "agent_resume": ( + lambda ns: ns.agent_pause(session_id="s", agent_id="a", pause_id="x"), + lambda ns: ns.agent_resume(session_id="s", agent_id="a", pause_id="x"), + ), +} + + +def _emit_pair_with_gap(event_type, gap_seconds, monkeypatch): + """Run one start/end pair with a controlled interval between them.""" + import datetime as _dt + + start_call, end_call = PAIRED[event_type] + recorder = _Recorder() + ns = EventNamespace(recorder) + + base = _dt.datetime(2026, 1, 1, tzinfo=_dt.timezone.utc) + clock = {"now": base} + monkeypatch.setattr(EventNamespace, "_now", staticmethod(lambda: clock["now"])) + + start_call(ns) + clock["now"] = base + _dt.timedelta(seconds=gap_seconds) + recorder.entries.clear() + end_call(ns) + return recorder.entries[0] + + +@pytest.mark.parametrize("event_type", sorted(PAIRED)) +def test_a_pair_spanning_more_than_u32_milliseconds_omits_the_duration(event_type, monkeypatch): + """~49.7 days is an ordinary lifetime for these pairs, not an abuse. + + A `human_wait` answered after a long weekend, an `agent_pause` resumed a + month later. Over the range, `pu32()` stores NULL at 200 OK — so an + unbounded value is not an error anywhere, just an empty column. + """ + over = (2**32 / 1000) + 60 # comfortably past 2**32 ms + payload = _emit_pair_with_gap(event_type, over, monkeypatch) + assert "duration_ms" not in payload, ( + f"{event_type} emitted {payload.get('duration_ms')}, which the server stores as NULL" + ) + + +@pytest.mark.parametrize("event_type", sorted(PAIRED)) +def test_a_backwards_clock_omits_the_duration_rather_than_going_negative(event_type, monkeypatch): + """`datetime.now()` is wall-clock, so an NTP step back yields a negative gap. + + `round()` keeps the sign, and a negative into an unsigned column is the same + silent NULL as an oversized one. + """ + payload = _emit_pair_with_gap(event_type, -5, monkeypatch) + assert "duration_ms" not in payload, f"{event_type} emitted a negative duration" + + +@pytest.mark.parametrize("event_type", sorted(PAIRED)) +def test_an_ordinary_gap_still_produces_a_duration(event_type, monkeypatch): + """The bound must not swallow the normal case it exists to protect.""" + payload = _emit_pair_with_gap(event_type, 1.5, monkeypatch) + assert payload["duration_ms"] == 1500 + assert isinstance(payload["duration_ms"], int) + + +@pytest.mark.parametrize("event_type", sorted(PAIRED)) +def test_the_upper_boundary_itself_is_kept(event_type, monkeypatch): + """Exactly 2**32 - 1 ms is representable; an off-by-one here drops real data.""" + payload = _emit_pair_with_gap(event_type, (2**32 - 1) / 1000, monkeypatch) + assert payload["duration_ms"] == 2**32 - 1 + + +def test_the_measured_and_the_caller_supplied_paths_enforce_the_same_range(): + """One range, two entry points. They drifted once already. + + `_validate_promoted_numeric` refused a caller anything outside 0..2**32-1 + while the SDK's own computation was unbounded — the same field, held to two + different standards depending on who produced it. + """ + import datetime as _dt + + base = _dt.datetime(2026, 1, 1, tzinfo=_dt.timezone.utc) + over = base + _dt.timedelta(milliseconds=2**32) + assert _events._measured_duration_ms(base, over) is None + with pytest.raises(ValueError): + _events._validate_promoted_numeric("duration_ms", 2**32) + + ok = base + _dt.timedelta(milliseconds=2**32 - 1) + assert _events._measured_duration_ms(base, ok) == 2**32 - 1 + _events._validate_promoted_numeric("duration_ms", 2**32 - 1) + + +# ───────────────────────────────────────────────────────────────────────────── +# session_id / agent_id — on every event, and skipped silently when wrong +# ───────────────────────────────────────────────────────────────────────────── + +#: Every public event method, with the arguments it needs besides the two ids. +ALL_METHODS = [ + ("agent_start", {}), ("agent_end", {}), + ("agent_pause", {"pause_id": "p"}), ("agent_resume", {"pause_id": "p"}), + ("tool_use", {"tool_name": "t", "tool_call_id": "c"}), + ("tool_result", {"tool_name": "t", "tool_call_id": "c"}), + ("model_request", {}), ("model_response", {}), + ("hook_triggered", {"hook_name": "h", "hook_id": "i"}), + ("hook_completed", {"hook_name": "h", "hook_id": "i"}), + ("error", {"error_type": "E", "message": "m"}), + ("human_wait", {"input_id": "i"}), ("human_input", {"input_id": "i"}), + ("human_pause", {}), ("human_interrupt", {}), +] + + +def test_the_method_list_here_covers_every_public_event(): + """Or a method added later silently skips the check below.""" + public = { + n for n in dir(EventNamespace) + if not n.startswith("_") and callable(getattr(EventNamespace, n)) + } + assert {m for m, _ in ALL_METHODS} == public + + +@pytest.mark.parametrize("method,extra", ALL_METHODS, ids=[m for m, _ in ALL_METHODS]) +@pytest.mark.parametrize("bad", [None, 123, {"x": 1}, ["a"], b"bytes"], ids=lambda v: type(v).__name__) +def test_a_non_string_session_id_is_refused_on_every_event(method, extra, bad): + """Ingest SKIPS these and answers 200 — `{"accepted":0,"skipped":1}`. + + Verified against the live server. Nothing upstream learns: the SDK reports + success, the collector deletes the batch, and the event is gone. `None` is + the realistic way in — an uninitialised variable, a lookup that missed. + """ + ns = EventNamespace(_Recorder()) + with pytest.raises(TypeError, match="session_id"): + getattr(ns, method)(session_id=bad, agent_id="a", **extra) + + +@pytest.mark.parametrize("method,extra", ALL_METHODS, ids=[m for m, _ in ALL_METHODS]) +def test_a_non_string_agent_id_is_refused_on_every_event(method, extra): + """`None` is no longer invalid — it means "resolve from scope" (below). + + `TypeError`, because the wrong TYPE was supplied — and because omitting a + required keyword argument was always a TypeError, so code catching one keeps + working now that identity is optional. + """ + ns = EventNamespace(_Recorder()) + with pytest.raises(TypeError, match="agent_id"): + getattr(ns, method)(session_id="s", agent_id=12345, **extra) + + +@pytest.mark.parametrize("method,extra", ALL_METHODS, ids=[m for m, _ in ALL_METHODS]) +def test_an_omitted_agent_id_resolves_rather_than_raising(method, extra): + """Omitting it is the ergonomic path the scopes exist for, not an error. + + With no `agent()` scope open it lands on `DEFAULT_AGENT_ID` — the convention + the skill already teaches — rather than raising. Inventing a *session* that + way would scatter one run across as many sessions as it has emit sites, so + only the agent has a default. + """ + recorder = _Recorder() + getattr(EventNamespace(recorder), method)(session_id="s", **extra) + assert recorder.entries[0]["agent_id"] == _context.DEFAULT_AGENT_ID + + +def test_an_unresolvable_session_id_raises_rather_than_emitting(): + """Nothing passed and nothing bound must be loud. + + Ingest skips an event whose `session_id` is not a JSON string and answers + `200 OK` with `{"accepted":0,"skipped":1}`, so emitting here would lose the + event with no error on either side — the exact failure the validation exists + to prevent, reached through the new ergonomic path instead of a bad argument. + """ + ns = EventNamespace(_Recorder()) + with pytest.raises(TypeError, match="session_id is required"): + ns.agent_start() + + +def test_an_ambient_scope_satisfies_both_ids(): + import failproofai_sdk + + recorder = _Recorder() + ns = EventNamespace(recorder) + with failproofai_sdk.session("sess-x"): + with failproofai_sdk.agent("planner"): + ns.agent_start(goal="from ambient scope") + entry = recorder.entries[-1] + assert entry["session_id"] == "sess-x" + assert entry["agent_id"] == "planner" + + +@pytest.mark.parametrize("blank", ["", " ", "\t\n"], ids=["empty", "spaces", "whitespace"]) +def test_a_blank_identity_is_refused_although_the_server_accepts_it(blank): + """The worse of the two outcomes, which is why it is refused too. + + A skipped event is at least absent. A blank id is ACCEPTED by the server, so + every event sent that way lands and is silently grouped under one id — the + data looks present and is quietly merged across unrelated runs. + """ + ns = EventNamespace(_Recorder()) + with pytest.raises(ValueError, match="empty"): + ns.agent_start(session_id=blank, agent_id="a") + with pytest.raises(ValueError, match="empty"): + ns.agent_start(session_id="s", agent_id=blank) + + +def test_ordinary_identities_are_untouched(): + recorder = _Recorder() + EventNamespace(recorder).agent_start(session_id="run-001", agent_id="planner") + assert recorder.entries[0]["session_id"] == "run-001" + assert recorder.entries[0]["agent_id"] == "planner" diff --git a/sdk/python/tests/test_site_docs.py b/sdk/python/tests/test_site_docs.py new file mode 100644 index 000000000..c9f8e0ceb --- /dev/null +++ b/sdk/python/tests/test_site_docs.py @@ -0,0 +1,270 @@ +"""The published docs site makes claims about this package, and nothing checked them. + +`docs/start/integrations/` on the site is a second copy of the integration guide, +maintained by hand alongside `sdk/python/docs/`. Two copies of the same claims is +exactly the shape that drifts, and it already had: the site named +`capture_content` for CrewAI and `session_id` for LlamaIndex, neither of which +those adapters read, and told readers to verify a Pydantic AI install by printing +`agent.capabilities`, which raises `AttributeError`. + +None of that produced an error for a reader — `instrument()` drops unknown option +keys by design — so only a test can catch it. + +Same shape as `test_spool_contract.py` and `fp-cli/tests/test_fp_home_contract.py`: +read the other side's source, skip when it is genuinely absent (an installed +sdist has no docs site), and fail when `FAILPROOFAI_SDK_REQUIRE_CONTRACT` says the +repository should be there. +""" +from __future__ import annotations + +import ast +import os +import re +import textwrap +from pathlib import Path + +import pytest + +import failproofai_sdk +from failproofai_sdk import _events as _events_module + +# sdk/python -> sdk -> repo root -> docs/ +_HERE = Path(failproofai_sdk.__file__).resolve().parent.parent +SITE = _HERE.parent.parent / "docs" / "start" / "integrations" + +REQUIRE = os.environ.get("FAILPROOFAI_SDK_REQUIRE_CONTRACT", "").strip().lower() in { + "1", + "true", + "yes", + "on", +} + +#: site page -> the adapter module it documents. +PAGE_ADAPTER = { + "langchain.mdx": "langchain", + "crewai.mdx": "crewai", + "llamaindex.mdx": "llama_index", + "pydantic-ai.mdx": "pydantic_ai", +} + + +def _page(name: str) -> str: + path = SITE / name + if path.is_file(): + return path.read_text(encoding="utf-8") + message = ( + f"{path} is missing. In an installed sdist that is expected. In the " + f"repository it means the docs site moved and these claims are now " + f"unguarded — re-point this test rather than deleting it." + ) + if REQUIRE: + pytest.fail(message) + pytest.skip(message) + + +def _adapter_options(module_name: str) -> set[str]: + source = ( + Path(failproofai_sdk.__file__).resolve().parent + / "integrations" + / f"{module_name}.py" + ).read_text(encoding="utf-8") + names = set(re.findall(r'options\.get\(\s*["\'](\w+)["\']', source)) + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.ClassDef) and node.name == "_Options": + names |= { + stmt.target.id + for stmt in node.body + if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name) + } + return names + + +def _documented_options(text: str, adapter: str) -> set[str]: + out: set[str] = set() + for block in re.findall(r"```python[^\n]*\n(.*?)```", text, re.S): + try: + tree = ast.parse(textwrap.dedent(block)) + except SyntaxError: + continue + for node in ast.walk(tree): + if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)): + continue + if node.func.attr != "instrument" or not node.args: + continue + first = node.args[0] + if isinstance(first, ast.Constant) and first.value == adapter: + out |= {kw.arg for kw in node.keywords if kw.arg} + return out + + +@pytest.mark.parametrize("page,adapter", sorted(PAGE_ADAPTER.items())) +def test_a_site_page_only_documents_options_the_adapter_reads(page, adapter): + documented = _documented_options(_page(page), adapter) + if not documented: + pytest.skip(f"{page} documents no instrument() options") + invented = documented - _adapter_options(adapter) + assert not invented, ( + f"docs/start/integrations/{page} documents instrument({adapter!r}, ...) " + f"options {sorted(invented)} that the adapter never reads. Unknown keys " + f"are dropped silently, so a reader gets no error and no effect." + ) + + +@pytest.mark.parametrize("page", sorted(PAGE_ADAPTER)) +def test_a_site_page_only_names_event_methods_that_exist(page): + named = set(re.findall(r"failproofai_sdk\.event\.([a-z_]+)\s*\(", _page(page))) + missing = {n for n in named if not hasattr(failproofai_sdk.event, n)} + assert not missing, f"{page} names event.{sorted(missing)}, which does not exist" + + +@pytest.mark.parametrize("page", sorted(PAGE_ADAPTER)) +def test_a_site_page_only_names_api_this_package_exports(page): + named = set(re.findall(r"failproofai_sdk\.([a-z_]+)\s*\(", _page(page))) - {"event"} + missing = {n for n in named if not hasattr(failproofai_sdk, n)} + assert not missing, f"{page} names failproofai_sdk.{sorted(missing)}, which does not exist" + + +def test_the_pydantic_page_verifies_the_capability_the_way_that_works(): + """`agent.capabilities` raises AttributeError. + + Pydantic AI merges the list you pass into a single `root_capability`, so the + verification snippet the page tells a reader to run has to go through it. + """ + text = _page("pydantic-ai.mdx") + assert "root_capability.capabilities" in text, ( + "the pydantic page must verify through `agent.root_capability.capabilities`" + ) + # Scoped to CODE, not prose: the page explains in words that + # `agent.capabilities` does not exist, and that sentence should stay. + code = "\n".join(re.findall(r"```python[^\n]*\n(.*?)```", text, re.S)) + assert not re.search(r"(?<!root_capability\.)\bagent\.capabilities\b", code), ( + "a code sample on the pydantic page reads `agent.capabilities`, " + "which raises AttributeError" + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Structure +# ───────────────────────────────────────────────────────────────────────────── +# +# Four framework pages written one at a time drifted into four different shapes: +# LangGraph explained session control under "Control the session", CrewAI buried +# the same thing as an H3 inside "Options", LlamaIndex had it in a callout, and +# Pydantic AI did not mention it at all. A reader who learns one page should be +# able to skim the next. + +#: The order every framework page follows. Pages may insert their own sections +#: between these, but these must appear, in this order. +SPINE = [ + "Install", + "Instrument", + "What gets recorded", + "Example", + "Name your spans", + "Control the session", + "Options", + "Common problems", + "Next", +] + + +def _headings(page: str) -> list[str]: + return [h.strip() for h in re.findall(r"^## (.+)$", _page(page), re.M)] + + +@pytest.mark.parametrize("page", sorted(PAGE_ADAPTER)) +def test_a_framework_page_follows_the_shared_spine(page): + headings = _headings(page) + missing = [h for h in SPINE if h not in headings] + assert not missing, f"{page} is missing sections {missing}; has {headings}" + + positions = [headings.index(h) for h in SPINE] + assert positions == sorted(positions), ( + f"{page} orders the shared sections differently: " + f"{[headings[i] for i in positions]}" + ) + + +@pytest.mark.parametrize("page", sorted(PAGE_ADAPTER)) +def test_a_framework_page_is_explicit_about_human_in_the_loop(page): + """Either it documents the pairs, or it says why there are none. + + Silence reads as an oversight, and the reader cannot tell whether the + framework has no HITL surface or we simply did not map it. + """ + text = _page(page) + assert "## Human in the loop" in text or "human-in-the-loop pair" in text, ( + f"{page} neither documents human-in-the-loop nor states that the " + f"framework has none" + ) + + +# --- Keyword arguments, on every page in the directory ---------------------- +# +# The tests above check that a method NAME exists. Nothing checked the keywords, +# and `event.*` ends in `**fields` — so a wrong one is accepted, stored as a +# custom field, and never populates the column the reader wanted. The docs told +# people to call `model_response(response=...)` for a long time; the parameter is +# `content`, so every reader who copied it got an event whose `content` column +# was empty and a stray `response` field they never asked for. Nothing raised. +# +# These also widen the net: PAGE_ADAPTER covers only the four framework pages, so +# `custom-agents.mdx` — which carries the most hand-written event calls on the +# site — was scanned by nothing at all. That is where the bug survived. + +ALL_PAGES = sorted(p.name for p in SITE.glob("*.mdx")) if SITE.is_dir() else [] + +# Custom fields are legal, and the adapters namespace theirs. A bare unknown +# keyword on a documented call is the typo case. +# +# Two exemptions, both deliberate. `fw_*` is the documented namespace for a +# framework's own metadata. And `_PROMOTED_NUMERIC` names the keys ingest lifts +# into real columns — `duration_ms` on `model_response` is the one the docs +# actively tell you to pass, and it travels through `**fields` by design, so the +# SDK validates it there rather than declaring it a parameter. Reading the set +# from the SDK keeps this from drifting the moment a fourth key is promoted. +_CUSTOM_FIELD_PREFIX = "fw_" +_PROMOTED = set(_events_module._PROMOTED_NUMERIC) + + +def _event_calls(text: str): + """(method, {keywords}) for every failproofai_sdk.event.X(...) in a python block.""" + for block in re.findall(r"```python[^\n]*\n(.*?)```", text, re.S): + try: + tree = ast.parse(textwrap.dedent(block)) + except SyntaxError: + continue + for node in ast.walk(tree): + if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)): + continue + value = node.func.value + if not (isinstance(value, ast.Attribute) and value.attr == "event"): + continue + yield node.func.attr, {kw.arg for kw in node.keywords if kw.arg} + + +@pytest.mark.parametrize("page", ALL_PAGES) +def test_every_documented_event_call_uses_real_keywords(page): + import inspect + + bad = [] + for method, keywords in _event_calls(_page(page)): + fn = getattr(failproofai_sdk.event, method, None) + if fn is None: + continue # the name test above owns this failure + real = set(inspect.signature(fn).parameters) - {"fields"} + for kw in sorted(keywords - real): + if kw.startswith(_CUSTOM_FIELD_PREFIX) or kw in _PROMOTED: + continue + bad.append(f"event.{method}({kw}=...)") + assert not bad, ( + f"docs/start/integrations/{page} passes keywords that are not parameters: " + f"{bad}. `event.*` ends in **fields, so these are accepted silently and " + f"stored as custom fields instead of filling the column the reader wanted." + ) + + +def test_the_keyword_scan_actually_finds_calls(): + """Otherwise a change to the block format makes every page above pass vacuously.""" + found = sum(len(list(_event_calls(_page(p)))) for p in ALL_PAGES) + assert found >= 10, f"only {found} event calls found across {len(ALL_PAGES)} pages" diff --git a/sdk/python/tests/test_skill_snippets.py b/sdk/python/tests/test_skill_snippets.py new file mode 100644 index 000000000..6c3c6c668 --- /dev/null +++ b/sdk/python/tests/test_skill_snippets.py @@ -0,0 +1,161 @@ +"""The skill's code is instructions an agent executes, so it is code under test. + +An agent reading `SKILL.md` copies these blocks into someone's real agent loop. +A bug in a documented snippet ships to every reader, and unlike the package it +is never exercised by anything — which is why it gets a guard rather than a +proofread. +""" +import ast +import re +import textwrap +from pathlib import Path + +import pytest + +SKILL = Path(__file__).resolve().parents[1] / "skill" +SNIPPET_FILES = sorted(SKILL.rglob("*.md")) + + +def python_blocks(path): + """Every ```python fenced block in a markdown file, dedented. + + Dedented because a fenced block nested inside a list item is indented in the + source and is still valid Python once that common prefix is removed. Without + this the only way to keep a snippet testable was to hoist it out of the list + it belongs to — so the guard was quietly shaping the prose. `test_site_docs.py` + already dedents; these two scans should not disagree about what a block is. + """ + text = path.read_text(encoding="utf-8") + blocks = re.findall(r"```python[^\n]*\n(.*?)```", text, re.DOTALL) + return [textwrap.dedent(b) for b in blocks] + + +def test_there_are_snippets_to_check(): + """A path typo would otherwise make every test below pass vacuously.""" + blocks = [b for p in SNIPPET_FILES for b in python_blocks(p)] + assert len(blocks) >= 5, f"found {len(blocks)} python blocks under {SKILL}" + + +@pytest.mark.parametrize("path", SNIPPET_FILES, ids=lambda p: p.name) +def test_every_documented_snippet_parses(path): + """A snippet that does not compile is worse than no snippet.""" + for i, block in enumerate(python_blocks(path)): + try: + ast.parse(block) + except SyntaxError as exc: # pragma: no cover - failure path + pytest.fail(f"{path.name} block {i} does not parse: {exc}") + + +@pytest.mark.parametrize("path", SNIPPET_FILES, ids=lambda p: p.name) +def test_lifecycle_brackets_catch_baseexception_not_exception(path): + """A cancelled tool or run must still emit its closing event. + + `asyncio.CancelledError` inherits straight from `BaseException`, so + `except Exception` does not see it — and cancellation is the ordinary way an + async tool ends when a timeout fires or a caller gives up, not an exotic + one. The result is a `tool_use` with no `tool_result`, or an `agent_start` + with no `agent_end`: an orphaned event that also holds its correlation slot + until the cap evicts it. + + Only handlers that EMIT are checked. A bare `except Exception` around the + emit call itself is correct and must stay — swallowing `KeyboardInterrupt` + there would make telemetry able to block a Ctrl-C. + """ + for i, block in enumerate(python_blocks(path)): + try: + tree = ast.parse(block) + except SyntaxError: + continue + for node in ast.walk(tree): + if not isinstance(node, ast.ExceptHandler): + continue + body = ast.unparse(ast.Module(body=node.body, type_ignores=[])) + emits = re.search(r"\b(_emit|event\.\w+|failproofai_sdk\.event\.\w+)\s*\(", body) + if not emits: + continue + caught = ast.unparse(node.type) if node.type else "<bare>" + assert "BaseException" in caught, ( + f"{path.name} block {i}: `except {caught}` wraps an emit — a cancelled " + "tool or run would skip its closing event. Use BaseException." + ) + + +# --- The prose is a contract too ------------------------------------------- +# +# Everything above checks that snippets PARSE and handle cancellation. Nothing +# checked that what the skill SAYS is true, and three claims had gone stale +# undetected: a correlation bug described as current after it was fixed, a float +# `duration_ms` described as silently dropped after it started raising, and a +# verify step pointing at the pre-migration spool root. An agent following that +# last one looks in an empty directory and reports the integration broken. +# +# These are the checks that would have caught each class. + +import failproofai_sdk +from failproofai_sdk import _resolver + +SKILL_TEXT = {p.name: p.read_text(encoding="utf-8") for p in SNIPPET_FILES} + + +def test_the_skill_only_names_event_methods_that_exist(): + """A method renamed in the SDK leaves the skill teaching a call that raises.""" + named = set() + for text in SKILL_TEXT.values(): + named |= set(re.findall(r"(?:failproofai_sdk\.)?event\.([a-z_]+)\s*\(", text)) + assert named, "no event.* calls found — the scan stopped working" + missing = sorted(n for n in named if not hasattr(failproofai_sdk.event, n)) + assert not missing, f"the skill names event methods that do not exist: {missing}" + + +def test_the_skill_only_names_public_api_that_exists(): + """Same, for the top-level surface an agent is told to import and call.""" + named = set(re.findall(r"failproofai_sdk\.([a-z_]+[a-z_0-9]*)\s*\(", "\n".join(SKILL_TEXT.values()))) + named -= {"event"} # a namespace, reached as failproofai_sdk.event.<method> + assert named, "no failproofai_sdk.* calls found — the scan stopped working" + missing = sorted(n for n in named if not hasattr(failproofai_sdk, n)) + assert not missing, f"the skill names public API that does not exist: {missing}" + + +def test_the_skill_verifies_against_the_current_spool_root(): + """The verify step must send a reader to the directory the SDK actually writes. + + `~/.agenteye` is still allowed in the migration notes — that is what the + older `agenteye-collector` reads, and the skill has to say so. What is not + allowed is presenting it as the path to CHECK, which is what it did: a fresh + integration with no env vars writes to `~/.failproofai/custom-agents/events`, + so the documented `ls` found nothing and read as total failure. + """ + default = str(_resolver.failproofai_custom_agents_dir()) + assert default.endswith("/.failproofai/custom-agents"), default + + skill = SKILL_TEXT["SKILL.md"] + assert "~/.failproofai/custom-agents/events" in skill, ( + "SKILL.md never names the current default spool directory" + ) + offenders = [ + line.strip() + for line in skill.splitlines() + if re.search(r"(ls|cat)\b[^\n]*~/\.agenteye/events", line) + ] + assert not offenders, ( + "SKILL.md tells the reader to inspect the pre-migration spool root; " + f"that directory is empty on a default install: {offenders}" + ) + + +def test_the_skill_does_not_brand_the_product_with_the_retired_name(): + """The H1 and the description are what an agent reads before anything else. + + `agenteye` stays legal in migration notes and in the collector's own name; + naming the PRODUCT that is what this catches. + """ + skill = SKILL_TEXT["SKILL.md"] + heading = next(line for line in skill.splitlines() if line.startswith("# ")) + assert "agenteye" not in heading.lower(), f"retired product name in the H1: {heading}" + + front = skill.split("---")[1] if skill.startswith("---") else "" + description = re.search(r"description:.*?(?=\n[a-z_]+:|\Z)", front, re.S) + assert description, "SKILL.md has no frontmatter description" + assert "to agenteye" not in description.group(0).lower(), ( + "the frontmatter description still says events are reported to AgentEye" + ) diff --git a/sdk/python/tests/test_spool_contract.py b/sdk/python/tests/test_spool_contract.py new file mode 100644 index 000000000..24bfbdbd5 --- /dev/null +++ b/sdk/python/tests/test_spool_contract.py @@ -0,0 +1,282 @@ +"""The SDK must write where the shipped daemons read. + +This is the cross-component test, and it exists because the divergence it checks +for is invisible from either side alone: the SDK's own tests pass while writing +to a directory nothing watches, and the daemon's own tests pass while watching a +directory nothing writes to. Both suites green, zero events uploaded, no error +anywhere. Silent total data loss is the failure mode this file is here to make +loud. + +Two daemons read this SDK's spool, and only one of them is in this repository: + + * ``failproofaid`` — here, in ``crates/fpai-collect``. Watches BOTH roots. + Checkable, and checked below without skipping. + * ``agenteye-collector`` — in the private AgentEye repository. Watches only + ``$AGENTEYE_HOME`` / ``~/.agenteye``. Checkable only when a checkout is on + disk, so it is opt-in via ``FP_AGENTEYE_ROOT``. + +The predecessor of this file gated EVERY test in it on a source file from that +other repository, so all four skipped in normal runs — including the three that +assert nothing but this SDK's own behaviour and need no other repo at all. A +test that always skips is not a guard. Hence the split below, and hence +``FAILPROOFAI_SDK_REQUIRE_CONTRACT=1``, which CI sets to turn the remaining +skips into failures so "the file moved" can never quietly read as "green". +""" +import os +import re +from pathlib import Path + +import pytest + +from failproofai_sdk import _resolver + +# tests/ -> python/ -> sdk/ -> repo root. +# +# Guarded, because `parents[3]` raises IndexError on a shallower tree — and a +# shallower tree is exactly the packaged-sdist case `_read_sibling` below is +# written to handle gracefully. Unguarded, it raised at IMPORT, which pytest +# reports as a collection error and which aborts the WHOLE suite rather than +# skipping the one file that needs the repo. Reproduced by copying `sdk/python` +# somewhere on its own and running pytest: 428 passing tests became `1 error`. +_HERE = Path(__file__).resolve() +REPO_ROOT = _HERE.parents[3] if len(_HERE.parents) > 3 else _HERE.parent +FPAI_COLLECT_CONFIG = REPO_ROOT / "crates" / "fpai-collect" / "src" / "config.rs" +FP_HOME_TS = REPO_ROOT / "src" / "hooks" / "fp-home.ts" + +#: Set by CI. Turns "the source I read is missing" from a skip into a failure. +REQUIRE = os.environ.get("FAILPROOFAI_SDK_REQUIRE_CONTRACT", "").strip().lower() in { + "1", + "true", + "yes", + "on", +} + + +def _read_sibling(path: Path) -> str: + """Source of a same-repo file, or skip/fail depending on ``REQUIRE``. + + Missing means one of two things: this is an installed sdist (fine — there is + no repo to read), or somebody moved the file (not fine). ``REQUIRE`` + distinguishes them, because from in here they look identical. + """ + if path.is_file(): + return path.read_text(encoding="utf-8") + message = ( + f"{path.relative_to(REPO_ROOT) if REPO_ROOT in path.parents else path} is " + "missing. In a packaged sdist that is expected. In the repository it means " + "the file moved, and this contract is now unguarded — re-point this test at " + "the new location rather than deleting it." + ) + if REQUIRE: + pytest.fail(message) + pytest.skip(message) + + +# ───────────────────────────────────────────────────────────────────────────── +# This SDK's own resolution rule. Depends on nothing outside this package, so +# it never skips, ever. +# ───────────────────────────────────────────────────────────────────────────── + + +def test_sdk_default_is_the_umbrella(tmp_path, monkeypatch): + """No configuration: the umbrella root, which failproofaid watches.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + monkeypatch.delenv("AGENTEYE_HOME", raising=False) + monkeypatch.delenv("FAILPROOFAI_HOME", raising=False) + _resolver.set_base_dir(None) + + assert _resolver.get_base_dir() == tmp_path / ".failproofai" / "custom-agents" + + +def test_the_legacy_root_stays_reachable_through_agenteye_home(tmp_path, monkeypatch): + """The escape hatch for a host still running `agenteye-collector`. + + That collector resolves `$AGENTEYE_HOME` or `~/.agenteye` and nothing else, + so on such a host the new default writes where it does not look — silently, + since an unread spool is indistinguishable from an idle one. This is the + supported way back, and it works because BOTH daemons honour the variable. + """ + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + monkeypatch.setenv("AGENTEYE_HOME", str(tmp_path / ".agenteye")) + _resolver.set_base_dir(None) + + assert _resolver.get_base_dir() == tmp_path / ".agenteye" + + +def test_agenteye_home_overrides_everything_below_it(tmp_path, monkeypatch): + """$AGENTEYE_HOME is the one override every component honours.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + monkeypatch.setenv("AGENTEYE_HOME", str(tmp_path / "shared")) + _resolver.set_base_dir(None) + + assert _resolver.get_base_dir() == tmp_path / "shared" + + +# ───────────────────────────────────────────────────────────────────────────── +# failproofaid — same repository, so these are hard assertions. +# ───────────────────────────────────────────────────────────────────────────── + + +def test_failproofaid_watches_the_legacy_agenteye_root(): + """``crates/fpai-collect`` must keep reading ``~/.agenteye``. + + This is the assertion that protects every already-deployed SDK. Dropping + this root from the daemon does not break a build or a test on the Rust side + — it just means an unupgraded SDK writes into a directory nothing reads. + """ + src = _read_sibling(FPAI_COLLECT_CONFIG) + + assert "fn agenteye_events_dir()" in src, ( + "crates/fpai-collect no longer defines agenteye_events_dir(). Every SDK " + "release to date writes to ~/.agenteye/events by default; removing that " + "root silently strands all of them." + ) + assert 'var_os("AGENTEYE_HOME")' in src + assert '".agenteye"' in src + assert 'join("events")' in src + + +def test_failproofaid_watches_both_roots_in_one_list(): + """Both spool roots must be in the daemon's watch list, not just defined.""" + src = _read_sibling(FPAI_COLLECT_CONFIG) + + match = re.search(r"for sdk_spool in \[([^\]]*)\]", src) + assert match, ( + "the spool_dirs loop in crates/fpai-collect/src/config.rs was " + "restructured. Find where the daemon now assembles the directories it " + "watches and re-point this assertion; a defined-but-unwatched root reads " + "as working from every side." + ) + watched = match.group(1) + assert "custom_agents_events_dir" in watched + assert "agenteye_events_dir" in watched + + +def test_umbrella_path_agrees_with_rust_and_typescript(): + """``~/.failproofai/custom-agents`` is spelled the same in all three languages.""" + rust = _read_sibling(FPAI_COLLECT_CONFIG) + typescript = _read_sibling(FP_HOME_TS) + + assert 'join("custom-agents")' in rust + assert '"custom-agents"' in typescript + assert 'atHome(home, "custom-agents")' in typescript, ( + "src/hooks/fp-home.ts no longer derives customAgentsDir from atHome(). " + "atHome() is what applies FAILPROOFAI_HOME, which _resolver mirrors." + ) + # The SDK's own spelling of the same path, under a fake home. + assert _resolver.failproofai_custom_agents_dir().name == "custom-agents" + + +def test_failproofai_home_is_honoured_the_same_way_on_both_sides(tmp_path, monkeypatch): + """``FAILPROOFAI_HOME`` moves the umbrella root for the SDK and the TS alike.""" + typescript = _read_sibling(FP_HOME_TS) + assert "process.env.FAILPROOFAI_HOME" in typescript + + monkeypatch.setenv("FAILPROOFAI_HOME", str(tmp_path / "elsewhere")) + assert _resolver.failproofai_custom_agents_dir() == ( + tmp_path / "elsewhere" / "custom-agents" + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# agenteye-collector — private repository. Opt-in via FP_AGENTEYE_ROOT. +# ───────────────────────────────────────────────────────────────────────────── + +_AGENTEYE_ROOT = os.environ.get("FP_AGENTEYE_ROOT") +_agenteye_collector_config = ( + Path(_AGENTEYE_ROOT) / "collector" / "src" / "config.rs" if _AGENTEYE_ROOT else None +) + +requires_agenteye_checkout = pytest.mark.skipif( + _agenteye_collector_config is None or not _agenteye_collector_config.is_file(), + reason="set FP_AGENTEYE_ROOT to an AgentEye checkout to verify the older collector", +) + + +@requires_agenteye_checkout +def test_agenteye_collector_still_reads_only_agenteye_home_and_dot_agenteye(): + """Pins the premise the SDK default rests on, on the older collector.""" + src = _agenteye_collector_config.read_text(encoding="utf-8") + start = src.find("pub fn base_dir()") + assert start != -1, ( + "the AgentEye collector's `pub fn base_dir()` is gone. It is the other " + "half of this contract — find where it now resolves its spool root and " + "re-point this test at it rather than deleting the test." + ) + end = src.find("\nfn ", start) + body = src[start : end if end != -1 else len(src)] + + assert 'var("AGENTEYE_HOME")' in body + assert '".agenteye"' in body + # The load-bearing half. This collector NOT knowing the umbrella is the + # entire reason `AGENTEYE_HOME` is documented as the escape hatch for hosts + # running it. If it ever learns the umbrella root, that advice becomes + # unnecessary and the migration note should be retired — deliberately, not + # by someone noticing years later. + assert "failproofai" not in body.lower(), ( + "the AgentEye collector now mentions failproofai in base_dir(). If it " + "genuinely watches ~/.failproofai/custom-agents, the AGENTEYE_HOME " + "escape hatch this SDK documents for legacy hosts is no longer needed " + "— retire it on purpose, not silently." + ) + + +def test_this_file_can_be_imported_outside_the_repository(): + """A collection error here aborts the whole suite, not just this file. + + `_read_sibling` is written to skip when the repo is absent — "in a packaged + sdist that is expected" — but `parents[3]` raised IndexError at import + before any of that could run, so the graceful path was unreachable in the + one situation it exists for. + """ + assert REPO_ROOT.is_absolute() + # The real repo has these; an sdist does not, and then the guards skip. + if FPAI_COLLECT_CONFIG.is_file(): + assert FP_HOME_TS.is_file(), "half the contract's sources are missing" + + +# --- The README is part of the contract too ----------------------------------- +# +# The three assertions above pin the spool root across Python, Rust and +# TypeScript, so the code cannot drift. Nothing pinned the README, and it drifted +# on its own: after the default moved to ~/.failproofai/custom-agents, the +# architecture diagram, the configure() comment and the JSONL example were all +# still showing ~/.agenteye/events — contradicted by correct prose two lines away. +# +# That is not a cosmetic miss. A reader follows the code sample, tails a +# directory nothing writes to, sees an empty spool, and concludes the SDK is +# broken — which is the same "an unread spool is indistinguishable from an idle +# one" failure this whole file exists to make loud, just relocated into the docs. + +README = Path(__file__).resolve().parents[1] / "README.md" + +# ~/.agenteye is still allowed to appear in the migration notes, which exist to +# tell legacy hosts what to set. What is NOT allowed is presenting it as where +# batches land today. +_SPOOL_FILE_LINE = re.compile(r"events/(\*|event-)[^\s`]*\.jsonl") + + +def test_readme_shows_the_current_spool_root_wherever_it_shows_a_batch_path(): + offenders = [ + f"README.md:{i}: {line.strip()}" + for i, line in enumerate(README.read_text(encoding="utf-8").split("\n"), 1) + if _SPOOL_FILE_LINE.search(line) and "custom-agents" not in line + ] + assert not offenders, ( + "the README shows a spool batch path that is not the current default " + f"({_resolver.failproofai_custom_agents_dir()}); a reader will tail a " + "directory nothing writes to:\n " + "\n ".join(offenders) + ) + + +def test_readme_documents_the_current_default_for_base_dir(): + """The `configure(base_dir=...)` comment must not name the retired root as the default.""" + text = README.read_text(encoding="utf-8") + assert "~/.failproofai/custom-agents" in text, ( + "the README never spells the current default spool root" + ) + for i, line in enumerate(text.split("\n"), 1): + if "Default:" in line and ".agenteye" in line: + assert "custom-agents" in line, ( + f"README.md:{i} gives the retired root as the default: {line.strip()}" + ) diff --git a/sdk/python/tests/test_spool_creation.py b/sdk/python/tests/test_spool_creation.py new file mode 100644 index 000000000..8da641beb --- /dev/null +++ b/sdk/python/tests/test_spool_creation.py @@ -0,0 +1,257 @@ +"""The SDK creates its spool path and NOTHING else, at every level. + +This SDK installs into other people's agent processes and now writes inside +`~/.failproofai`, a directory the CLI and the daemon own. So the rule is not +merely "make the directory work" — it is that a machine which has only ever run +this SDK must be indistinguishable, to every other component, from a machine +that has run nothing at all. + +`detectLayout()` in `src/hooks/fp-config.ts` is the reason that matters. It +decides whether a home is `absent`, `current`, `stale` or `future`, and a +`stale` verdict authorises `resetHome()`, which deletes files. Its landmarks are +`VERSION`, `config.json`, `config.toml`, and layout 1's seven markers. Creating +any of those from here would hand the CLI a half-built home it believes it wrote +— so the only safe thing to create is the spool path itself. + +The three cases below are the three states a machine can be in, and each asserts +the EXACT set of paths that appear. An assertion on "the events dir exists" would +pass just as happily if a `VERSION` file appeared beside it. +""" +import json +import os +import stat + +import pytest + +from failproofai_sdk import _resolver +from failproofai_sdk._events import EventNamespace +from failproofai_sdk._writer import EventWriter + + +@pytest.fixture +def home(tmp_path, monkeypatch): + """An isolated HOME with no failproofai directory at all.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("FAILPROOFAI_HOME", str(tmp_path / ".failproofai")) + monkeypatch.delenv("AGENTEYE_HOME", raising=False) + _resolver.set_base_dir(None) + yield tmp_path + _resolver.set_base_dir(None) + + +def tree(root): + """Every path under `root`, relative and sorted. The whole filesystem effect.""" + if not root.exists(): + return [] + return sorted(str(p.relative_to(root)) for p in root.rglob("*")) + + +def emit_one(goal="e"): + writer = EventWriter(flush_interval=3600) + EventNamespace(writer).agent_start(session_id="s", agent_id="a", goal=goal) + writer.flush_now() + return writer + + +#: What a single published batch must add, and the complete list of it. +def spool_paths(batch_names): + return sorted(["custom-agents", "custom-agents/events", *[f"custom-agents/events/{n}" for n in batch_names]]) + + +# ───────────────────────────────────────────────────────────────────────────── +# Case 1 — the umbrella spool already exists: add a batch, create nothing else +# ───────────────────────────────────────────────────────────────────────────── + + +def test_when_the_spool_exists_only_a_batch_file_appears(home): + fp = home / ".failproofai" + (fp / "custom-agents" / "events").mkdir(parents=True) + before = tree(fp) + + emit_one() + + after = tree(fp) + added = [p for p in after if p not in before] + assert len(added) == 1, f"expected one new batch file, got {added}" + assert added[0].startswith("custom-agents/events/event-") + assert added[0].endswith(".jsonl") + + +def test_an_existing_failproofai_home_is_not_otherwise_disturbed(home): + """A configured machine must come through completely untouched. + + Not just "the files are still there" — unchanged. A rewritten `config.json` + with identical content would still be a component writing into a file it + does not own. + """ + fp = home / ".failproofai" + (fp / "custom-agents" / "events").mkdir(parents=True) + (fp / "VERSION").write_text('{"layout":4,"cli":"1.0.1"}') + (fp / "config.json").write_text('{"mode":{"kind":"oss"}}') + (fp / "credentials.json").write_text('{"token":"secret"}') + (fp / "policies").mkdir() + (fp / "policies" / "mine.mjs").write_text("// mine") + (fp / "hook-activity").mkdir() + + owned = { + p: (p.read_bytes(), p.stat().st_mtime_ns) + for p in (fp / "VERSION", fp / "config.json", fp / "credentials.json", fp / "policies" / "mine.mjs") + } + + emit_one() + + for path, (content, mtime) in owned.items(): + assert path.read_bytes() == content, f"{path.name} was rewritten" + assert path.stat().st_mtime_ns == mtime, f"{path.name} was touched" + assert (fp / "hook-activity").is_dir() + assert sorted(p.name for p in fp.iterdir()) == [ + "VERSION", "config.json", "credentials.json", "custom-agents", "hook-activity", "policies", + ] + + +# ───────────────────────────────────────────────────────────────────────────── +# Case 2 — the home exists, the spool does not: create only the spool +# ───────────────────────────────────────────────────────────────────────────── + + +def test_when_the_home_exists_only_the_spool_path_is_created(home): + fp = home / ".failproofai" + fp.mkdir() + (fp / "VERSION").write_text('{"layout":4,"cli":"1.0.1"}') + before = tree(fp) + + emit_one() + + added = [p for p in tree(fp) if p not in before] + batch = [p for p in added if p.endswith(".jsonl")] + assert len(batch) == 1 + assert sorted(added) == spool_paths([batch[0].rsplit("/", 1)[-1]]), ( + f"created something beyond the spool path: {added}" + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Case 3 — nothing exists: create the home and the spool, and stop there +# ───────────────────────────────────────────────────────────────────────────── + + +def test_when_nothing_exists_only_the_home_and_the_spool_are_created(home): + fp = home / ".failproofai" + assert not fp.exists() + + emit_one() + + assert fp.is_dir() + created = tree(fp) + batch = [p for p in created if p.endswith(".jsonl")] + assert len(batch) == 1 + assert created == spool_paths([batch[0].rsplit("/", 1)[-1]]), ( + f"a fresh home got more than the spool: {created}" + ) + + +@pytest.mark.parametrize( + "landmark", + ["VERSION", "config.json", "config.toml", "policies-config.json", "last-version", "cloud.json"], +) +def test_no_layout_landmark_is_ever_created(home, landmark): + """Each of these tells `detectLayout()` a different story, none of them true. + + `VERSION`/`config.json` would report a home this SDK never built as + `current`; `config.toml` and layout 1's markers report it `stale`, which is + what authorises `resetHome()` to start deleting. The SDK has no business + voting on any of it. + """ + emit_one() + assert not (home / ".failproofai" / landmark).exists() + + +def test_a_home_built_only_by_the_sdk_looks_unconfigured(home): + """The cross-component property, asserted from this side. + + Verified against the real `detectLayout()` too: a home holding only + `custom-agents/` returns `{kind: "absent"}` and `isConfigured()` is false. + Pinned here so a change on THIS side that starts writing a landmark fails in + the SDK's own suite rather than in the CLI's, months later. + """ + emit_one() + fp = home / ".failproofai" + assert sorted(p.name for p in fp.iterdir()) == ["custom-agents"] + + +# ───────────────────────────────────────────────────────────────────────────── +# Repeat writes, permissions, and the failure paths +# ───────────────────────────────────────────────────────────────────────────── + + +def test_repeated_flushes_do_not_recreate_or_churn_the_directories(home): + writer = EventWriter(flush_interval=3600) + ns = EventNamespace(writer) + ns.agent_start(session_id="s", agent_id="a", goal="one") + writer.flush_now() + + events = home / ".failproofai" / "custom-agents" / "events" + inode = events.stat().st_ino + + for i in range(5): + ns.agent_start(session_id="s", agent_id="a", goal=f"more-{i}") + writer.flush_now() + + assert events.stat().st_ino == inode, "the events directory was replaced" + assert len(list(events.glob("*.jsonl"))) == 6 + assert list(events.glob("*.tmp")) == [], "a temp file was left behind" + + +def test_created_directories_are_owner_writable_and_not_world_writable(home): + emit_one() + for d in ( + home / ".failproofai", + home / ".failproofai" / "custom-agents", + home / ".failproofai" / "custom-agents" / "events", + ): + mode = stat.S_IMODE(d.stat().st_mode) + assert mode & stat.S_IRWXU == stat.S_IRWXU, f"{d} is not owner-rwx" + assert not mode & stat.S_IWOTH, f"{d} is world-writable" + + +@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores directory permissions") +def test_an_unwritable_home_raises_and_keeps_the_events_queued(home): + """A permission error must not be mistaken for a delivered batch. + + `_flush` returns the drained entries to the queue and re-raises, so the next + interval retries them. Dropping here would lose live events to a condition + that is usually temporary — a home mounted read-only, a directory an + installer briefly re-owned. + """ + fp = home / ".failproofai" + fp.mkdir(mode=0o500) + try: + writer = EventWriter(flush_interval=3600) + EventNamespace(writer).agent_start(session_id="s", agent_id="a", goal="held") + with pytest.raises(OSError): + writer.flush_now() + assert len(writer._queue) == 1, "events were dropped on a permission error" + finally: + fp.chmod(0o700) + + +def test_agenteye_home_still_bypasses_the_umbrella_entirely(home, monkeypatch): + """The legacy escape hatch must create the legacy tree and nothing else.""" + legacy = home / ".agenteye" + monkeypatch.setenv("AGENTEYE_HOME", str(legacy)) + _resolver.set_base_dir(None) + + emit_one() + + assert not (home / ".failproofai").exists(), "the umbrella was created anyway" + assert tree(legacy) == sorted(["events", *[f"events/{p.name}" for p in (legacy / "events").iterdir()]]) + + +def test_the_batch_written_is_readable_and_carries_the_event(home): + """Creating directories is worthless if the payload does not survive it.""" + emit_one(goal="round-trip") + events = home / ".failproofai" / "custom-agents" / "events" + (batch,) = list(events.glob("*.jsonl")) + rows = [json.loads(line) for line in batch.read_text(encoding="utf-8").splitlines()] + assert [r["goal"] for r in rows] == ["round-trip"] + assert rows[0]["type"] == "agent_start" diff --git a/sdk/python/tests/test_wire_format.py b/sdk/python/tests/test_wire_format.py new file mode 100644 index 000000000..aa75f7a0a --- /dev/null +++ b/sdk/python/tests/test_wire_format.py @@ -0,0 +1,227 @@ +"""The bytes on disk are the product. This file freezes them. + +Everything downstream — the daemon that uploads the batch, the ingest handler +that promotes fields into indexed columns, the dedup key computed over the +whole payload — consumes exactly the JSON this module produces, and none of it +is generated from a shared schema. There is no build step that would notice a +renamed key, no type that spans the boundary, and no non-2xx response when a +field goes missing: ingest returns 200 and the column is simply NULL. + +So the assertions here are deliberately literal. A golden line per event type, +compared byte for byte, including key ORDER — because `dedup.rs` hashes the +canonical form of the payload, and a reordering that looks cosmetic here stops +retried batches collapsing against rows already stored, which shows up as +silent duplicate events rather than an error. + +If one of these fails, the question is never "how do I update the golden". It +is "what did I just change about the wire format, and who else has to change +with me". +""" +import json +from dataclasses import dataclass +from datetime import date, datetime, timezone +from decimal import Decimal +from enum import Enum +from pathlib import Path +from uuid import UUID + +import pytest + +from failproofai_sdk import _schema + +TS = "2026-01-02T03:04:05.678901Z" +BASE = dict(timestamp=TS, session_id="sess-1", agent_id="agent-1") + +# Every event type, fully populated, serialized exactly as `_writer` serializes it. +GOLDEN = { + "tool_use": '{"timestamp": "2026-01-02T03:04:05.678901Z", "session_id": "sess-1", "agent_id": "agent-1", "type": "tool_use", "tool_name": "bash", "tool_call_id": "tc-1", "environment": "prod", "input": {"cmd": "ls"}}', + "tool_result": '{"timestamp": "2026-01-02T03:04:05.678901Z", "session_id": "sess-1", "agent_id": "agent-1", "type": "tool_result", "tool_name": "bash", "tool_call_id": "tc-1", "environment": "prod", "output": "ok", "duration_ms": 12}', + "model_request": '{"timestamp": "2026-01-02T03:04:05.678901Z", "session_id": "sess-1", "agent_id": "agent-1", "type": "model_request", "environment": "prod", "model": "claude-opus-5", "messages": [{"role": "user"}], "system": "sys", "tools": [{"name": "t"}]}', + "model_response": '{"timestamp": "2026-01-02T03:04:05.678901Z", "session_id": "sess-1", "agent_id": "agent-1", "type": "model_response", "environment": "prod", "model": "claude-opus-5", "stop_reason": "end_turn", "input_tokens": 10, "output_tokens": 20, "content": "hi", "role": "assistant"}', + "agent_start": '{"timestamp": "2026-01-02T03:04:05.678901Z", "session_id": "sess-1", "agent_id": "agent-1", "type": "agent_start", "environment": "prod", "goal": "do it", "parent_id": "p-1"}', + "agent_end": '{"timestamp": "2026-01-02T03:04:05.678901Z", "session_id": "sess-1", "agent_id": "agent-1", "type": "agent_end", "environment": "prod", "outcome": "success", "summary": "done"}', + "agent_pause": '{"timestamp": "2026-01-02T03:04:05.678901Z", "session_id": "sess-1", "agent_id": "agent-1", "type": "agent_pause", "pause_id": "p-1", "environment": "prod", "reason": "quota", "user_id": "u-1"}', + "agent_resume": '{"timestamp": "2026-01-02T03:04:05.678901Z", "session_id": "sess-1", "agent_id": "agent-1", "type": "agent_resume", "pause_id": "p-1", "environment": "prod", "duration_ms": 34, "reason": "ok", "user_id": "u-1"}', + "hook_triggered": '{"timestamp": "2026-01-02T03:04:05.678901Z", "session_id": "sess-1", "agent_id": "agent-1", "type": "hook_triggered", "hook_name": "pre", "hook_id": "h-1", "environment": "prod", "trigger_event": "PreToolUse", "input": {"a": 1}}', + "hook_completed": '{"timestamp": "2026-01-02T03:04:05.678901Z", "session_id": "sess-1", "agent_id": "agent-1", "type": "hook_completed", "hook_name": "pre", "hook_id": "h-1", "environment": "prod", "outcome": "allow", "output": "o", "duration_ms": 56}', + "error": '{"timestamp": "2026-01-02T03:04:05.678901Z", "session_id": "sess-1", "agent_id": "agent-1", "type": "error", "error_type": "ValueError", "message": "boom", "environment": "prod", "traceback": "tb"}', + "human_wait": '{"timestamp": "2026-01-02T03:04:05.678901Z", "session_id": "sess-1", "agent_id": "agent-1", "type": "human_wait", "input_id": "i-1", "environment": "prod", "prompt": "ok?", "options": ["y", "n"], "reason": "approval"}', + "human_input": '{"timestamp": "2026-01-02T03:04:05.678901Z", "session_id": "sess-1", "agent_id": "agent-1", "type": "human_input", "input_id": "i-1", "environment": "prod", "response": "y", "duration_ms": 78}', + "human_pause": '{"timestamp": "2026-01-02T03:04:05.678901Z", "session_id": "sess-1", "agent_id": "agent-1", "type": "human_pause", "environment": "prod", "reason": "lunch", "user_id": "u-1"}', + "human_interrupt": '{"timestamp": "2026-01-02T03:04:05.678901Z", "session_id": "sess-1", "agent_id": "agent-1", "type": "human_interrupt", "environment": "prod", "reason": "stop", "user_id": "u-1", "at_step": "3"}', +} + + +def _events(): + """One fully-populated instance of every event dataclass.""" + s = _schema + return { + "tool_use": s.ToolUseEvent(**BASE, tool_name="bash", tool_call_id="tc-1", input={"cmd": "ls"}), + "tool_result": s.ToolResultEvent(**BASE, tool_name="bash", tool_call_id="tc-1", output="ok", error=None, duration_ms=12), + "model_request": s.ModelRequestEvent(**BASE, model="claude-opus-5", messages=[{"role": "user"}], system="sys", tools=[{"name": "t"}]), + "model_response": s.ModelResponseEvent(**BASE, model="claude-opus-5", stop_reason="end_turn", input_tokens=10, output_tokens=20, content="hi", role="assistant"), + "agent_start": s.AgentStartEvent(**BASE, goal="do it", parent_id="p-1"), + "agent_end": s.AgentEndEvent(**BASE, outcome="success", summary="done"), + "agent_pause": s.AgentPauseEvent(**BASE, pause_id="p-1", reason="quota", user_id="u-1"), + "agent_resume": s.AgentResumeEvent(**BASE, pause_id="p-1", duration_ms=34, reason="ok", user_id="u-1"), + "hook_triggered": s.HookTriggeredEvent(**BASE, hook_name="pre", hook_id="h-1", trigger_event="PreToolUse", input={"a": 1}), + "hook_completed": s.HookCompletedEvent(**BASE, hook_name="pre", hook_id="h-1", outcome="allow", output="o", error=None, duration_ms=56), + "error": s.ErrorEvent(**BASE, error_type="ValueError", message="boom", traceback="tb"), + "human_wait": s.HumanWaitEvent(**BASE, input_id="i-1", prompt="ok?", options=["y", "n"], reason="approval"), + "human_input": s.HumanInputEvent(**BASE, input_id="i-1", response="y", duration_ms=78), + "human_pause": s.HumanPauseEvent(**BASE, reason="lunch", user_id="u-1"), + "human_interrupt": s.HumanInterruptEvent(**BASE, reason="stop", user_id="u-1", at_step="3"), + } + + +@pytest.fixture(autouse=True) +def _fixed_environment(monkeypatch): + """Pin `environment`, which every event stamps from module state.""" + monkeypatch.setenv("AGENTEYE_ENVIRONMENT", "prod") + from failproofai_sdk import _environment + + monkeypatch.setattr(_environment, "_environment", None) + + +@pytest.mark.parametrize("event_type", sorted(GOLDEN)) +def test_serialized_line_is_byte_for_byte_frozen(event_type): + """Key names, key ORDER, and value encoding — all of it is the contract.""" + event = _events()[event_type] + # Exactly how `_writer._write_batch` serializes: default=str, no sort_keys. + line = json.dumps(event.to_dict(), default=str) + assert line == GOLDEN[event_type] + + +def test_every_schema_dataclass_has_a_golden(): + """A new event type must arrive with its wire format frozen, not after. + + Without this, adding a dataclass and forgetting the golden leaves the new + type's bytes unpinned — and the parametrized test above passes, because it + iterates the goldens rather than the schema. + """ + import dataclasses + import inspect + + declared = { + name + for name, obj in inspect.getmembers(_schema, inspect.isclass) + if dataclasses.is_dataclass(obj) and name.endswith("Event") + } + covered = {type(e).__name__ for e in _events().values()} + assert declared == covered, ( + f"schema dataclasses without a golden line: {sorted(declared - covered)}. " + "Add the event to _events() and GOLDEN before shipping it — the wire " + "format is the one thing nothing downstream will tell you about." + ) + assert len(GOLDEN) == len(declared) + + +def test_type_string_matches_the_key_it_is_registered_under(): + """`type` is a free string at ingest — no enum, no allowlist, anywhere. + + A typo ingests with HTTP 200 and `accepted: 1`, then never matches a filter. + This is the only place that spelling is checked. + """ + for event_type, event in _events().items(): + assert event.to_dict()["type"] == event_type + + +def test_none_valued_fields_are_omitted_not_serialized_as_null(): + """Absent means absent. `null` would overwrite a promoted column with NULL.""" + event = _schema.ToolResultEvent( + **BASE, tool_name="bash", tool_call_id="tc-1", output=None, error=None, duration_ms=None + ) + payload = event.to_dict() + for absent in ("output", "error", "duration_ms"): + assert absent not in payload + assert "null" not in json.dumps(payload) + + +def test_the_five_always_present_keys_are_always_present(): + """ingest.rs rejects a line missing any of the first four; the fifth is filtered on.""" + for event in _events().values(): + payload = event.to_dict() + for required in ("timestamp", "session_id", "agent_id", "type", "environment"): + assert required in payload, f"{type(event).__name__} is missing {required}" + + +def test_custom_fields_are_merged_last_and_cannot_shadow_a_schema_key(): + """Extra fields land in the payload; the schema's own keys win their slot.""" + event = _schema.AgentStartEvent(**BASE, goal="g", extra_fields={"trace_id": "abc", "cost": 0.5}) + payload = event.to_dict() + assert payload["trace_id"] == "abc" + assert payload["cost"] == 0.5 + # Insertion order: schema keys first, extras appended. + assert list(payload)[-2:] == ["trace_id", "cost"] + + +class _Colour(Enum): + RED = "red" + + +@dataclass +class _Unserializable: + x: int + + +@pytest.mark.parametrize( + "value", + [ + datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc), + date(2026, 1, 2), + UUID("12345678-1234-5678-1234-567812345678"), + Decimal("1.25"), + Path("/tmp/x"), + _Colour.RED, + _Unserializable(x=1), + {1, 2, 3}, + b"bytes", + ], +) +def test_unserializable_payload_values_are_coerced_never_dropped(value): + """`default=str` exists so one exotic value cannot kill the whole batch. + + The writer serializes a batch as a single string. Without the coercion, one + `Decimal` in one event raises inside `_write_batch`, the batch is requeued, + and the next flush raises on the same value — the queue never drains again + and every subsequent event in the process is lost too. + """ + event = _schema.AgentStartEvent(**BASE, extra_fields={"v": value}) + line = json.dumps(event.to_dict(), default=str) + assert json.loads(line)["v"] == str(value) + + +def test_duration_ms_is_serialized_as_an_integer(): + """A float here silently NULLs the column: the server's `as_u64()` drops it.""" + for name in ("tool_result", "agent_resume", "hook_completed", "human_input"): + payload = _events()[name].to_dict() + assert isinstance(payload["duration_ms"], int) + assert not isinstance(payload["duration_ms"], bool) + assert "." not in json.dumps(payload["duration_ms"]) + + +def test_a_batch_is_newline_delimited_with_a_trailing_newline(): + """The daemon splits on newlines and POSTs the bytes verbatim as x-ndjson.""" + events = [e.to_dict() for e in list(_events().values())[:3]] + content = "\n".join(json.dumps(e, default=str) for e in events) + "\n" + + assert content.endswith("\n") + lines = content.split("\n")[:-1] + assert len(lines) == 3 + for line in lines: + assert "\n" not in line + json.loads(line) + + +def test_no_payload_value_can_smuggle_a_newline_into_the_batch(): + """One raw newline in a value would split one event into two malformed lines. + + `json.dumps` escapes it, and ingest counts unparseable lines as `skipped` + while still returning 200 — so a regression here is silent partial loss. + """ + event = _schema.ErrorEvent( + **BASE, error_type="E", message="line one\nline two\r\nline three", traceback=None + ) + line = json.dumps(event.to_dict(), default=str) + assert "\n" not in line + assert json.loads(line)["message"] == "line one\nline two\r\nline three" diff --git a/sdk/python/tests/test_zero_dependencies.py b/sdk/python/tests/test_zero_dependencies.py new file mode 100644 index 000000000..eef089e0f --- /dev/null +++ b/sdk/python/tests/test_zero_dependencies.py @@ -0,0 +1,337 @@ +"""This SDK imports nothing outside the standard library, and must not start. + +It is installed into other people's agent processes. Every dependency we declare +becomes a version constraint on their application, and a resolver conflict we +cause is one they have to solve — in the observability library, which is the last +place anyone wants to spend an afternoon. "Zero dependencies" is the reason it is +safe to add to an existing project without thinking, so it is a promise rather +than a coincidence, and a promise needs a test. + +The framework adapters under `integrations/` are the one exception, and they do +not weaken the promise. Each one imports the framework it adapts — there is no +other way to subclass its callback base class — but they are reached ONLY through +`instrument()`, which resolves them by string through `importlib.import_module` +at call time. So `import failproofai_sdk` still touches nothing outside the +standard library, and an adapter's import can only run in a process where that +framework was already installed and imported. Both halves of that are asserted +below, the second by launching a fresh interpreter rather than by reading source. + +The declaration and the code are checked separately because they fail +separately: a stray `import httpx` in a rarely-taken branch is an ImportError in +the user's process at exactly the wrong moment, and an unused `dependencies` +entry drags a package into every install for nothing. CI proves the third case +the source cannot — that the built wheel really installs with `--no-deps`. +""" + +from __future__ import annotations + +import ast +import sys + +import pytest +from pathlib import Path + +try: # Python 3.11+ + import tomllib +except ModuleNotFoundError: # 3.10 — `tomli` is the same parser, from the dev extra + # Deliberately not a skip. The manifest assertions below are the enforcement + # of the zero-dependency promise, and a promise that stops being checked on + # the oldest interpreter we advertise is checked where it matters least. + # `tomli` is a TEST dependency; `[project.dependencies]` stays empty, which + # is the thing actually being promised. + import tomli as tomllib + +import failproofai_sdk + +PKG = Path(failproofai_sdk.__file__).resolve().parent +ROOT = PKG.parent +PYPROJECT = ROOT / "pyproject.toml" + +#: Everything the package is allowed to import: the standard library, itself, and +#: nothing else. `sys.stdlib_module_names` is the interpreter's own list, so this +#: stays correct across versions instead of being a hand-maintained allowlist. +ALLOWED = set(sys.stdlib_module_names) | {"failproofai_sdk"} + + +#: Adapters are allowed to import the framework they adapt. Nothing else is, and +#: this set is deliberately explicit rather than "anything under integrations/": +#: a new file added there gets scanned like core code until it is named here. +ADAPTER_IMPORTS = { + "langchain.py": {"langchain_core", "langgraph"}, + "crewai.py": {"crewai"}, + "llama_index.py": {"llama_index", "llama_index_instrumentation", "pydantic"}, + "pydantic_ai.py": {"pydantic_ai"}, +} + +#: Framework top-level names that must never appear in `sys.modules` after a bare +#: `import failproofai_sdk`. +FRAMEWORK_ROOTS = { + "langchain", "langchain_core", "langgraph", "crewai", + "llama_index", "llama_index_instrumentation", "pydantic", "pydantic_ai", +} + + +def _module_sources() -> list[Path]: + return sorted(p for p in PKG.rglob("*.py") if "__pycache__" not in p.parts) + + +def _allowed_for(path: Path) -> set[str]: + """What this module may import beyond the standard library.""" + if path.parent.name == "integrations": + return ALLOWED | ADAPTER_IMPORTS.get(path.name, set()) + return ALLOWED + + +def _imported_top_level_modules(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + modules: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + modules.add(alias.name.split(".")[0]) + elif isinstance(node, ast.ImportFrom): + # A relative import (`from . import x`) has level > 0 and no external + # module to check. + if node.level == 0 and node.module: + modules.add(node.module.split(".")[0]) + return modules + + +def test_the_package_imports_only_the_standard_library(): + """Including imports inside functions, which `ast.walk` reaches too. + + `_environment.get_environment` really does `import os` inside the function + body, so a check that only read module-level imports would miss a whole class + of dependency — the deferred one somebody adds to keep import time down. + """ + offenders: dict[str, set[str]] = {} + for path in _module_sources(): + external = _imported_top_level_modules(path) - _allowed_for(path) + if external: + offenders[path.name] = external + + assert not offenders, ( + f"the SDK imports non-stdlib modules: {offenders}. This package is " + "installed into other people's agent processes, where every dependency " + "we add is a constraint they have to satisfy. If one is genuinely " + "unavoidable, it needs a deliberate decision and a `dependencies` entry — " + "not an import that fails at runtime in a branch CI never took." + ) + + +def test_the_scan_found_the_modules_it_was_meant_to_scan(): + """Guards against the check above passing because it read nothing.""" + names = {p.name for p in _module_sources()} + assert {"_writer.py", "_events.py", "_schema.py", "_resolver.py"} <= names, ( + f"the package walk found only {sorted(names)}" + ) + + +def test_no_runtime_dependencies_are_declared(): + """The manifest half of the same promise.""" + manifest = tomllib.loads(PYPROJECT.read_text(encoding="utf-8")) + declared = manifest["project"].get("dependencies", []) + assert declared == [], ( + f"pyproject declares runtime dependencies: {declared}. Installing this " + "SDK must not pull anything in." + ) + + +#: The only distributions the dev extra may name, each with the reason it is there. +#: This is an allowlist rather than a count, because the failure it prevents is a +#: convenience library drifting in — anything here is one `--extra dev` away from a +#: user's environment, and none of it is covered by the zero-dependency promise. +ALLOWED_DEV_DEPENDENCIES = { + "pytest": "the test runner", + "pytest-asyncio": "the adapters are half-async; their scopes are exercised " + "under `async with`, which needs an async test runner", + "tomli": "tomllib's backport; Python 3.10 has no stdlib TOML parser, and " + "test_zero_dependencies.py must not stop checking the manifest there", +} + +#: Extras that exist only to pull a FRAMEWORK in, mapped to the distribution each +#: is allowed to name. The adapter code always ships in the base wheel and lazy- +#: imports, so these gate nothing on our side — they are a convenience for users +#: who do not already have the framework. +FRAMEWORK_EXTRAS = { + "langchain": {"langchain-core"}, + "langgraph": {"langgraph"}, + "crewai": {"crewai", "onnxruntime"}, + "llamaindex": {"llama-index-core"}, + "pydantic-ai": {"pydantic-ai-slim"}, +} + + +def _named(spec: str) -> str: + """"crewai>=1.13,<2; python_version < '3.11'" -> "crewai".""" + head = spec.split(";")[0].strip().split("[")[0] + for op in ("===", "~=", "!=", ">=", "<=", "==", ">", "<"): + head = head.split(op)[0] + return head.strip() + + +def test_dev_dependencies_are_test_only_and_stay_out_of_the_install(): + """`[project.optional-dependencies].dev` never reaches a plain install.""" + manifest = tomllib.loads(PYPROJECT.read_text(encoding="utf-8")) + extras = manifest["project"].get("optional-dependencies", {}) + assert set(extras) == {"dev"} | set(FRAMEWORK_EXTRAS), ( + f"unexpected extras: {sorted(set(extras) - {'dev'} - set(FRAMEWORK_EXTRAS))}" + ) + + named = {_named(spec) for spec in extras["dev"]} + unexpected = named - set(ALLOWED_DEV_DEPENDENCIES) + assert not unexpected, ( + f"the dev extra grew beyond the test tooling: {sorted(unexpected)}. Each " + "entry needs a reason in ALLOWED_DEV_DEPENDENCIES, because anything here " + "is one `--extra dev` away from a user's environment." + ) + + +def test_the_package_declares_itself_typed(): + """`py.typed` must ship, or the annotations are invisible to a type checker.""" + assert (PKG / "py.typed").is_file() + manifest = tomllib.loads(PYPROJECT.read_text(encoding="utf-8")) + package_data = manifest["tool"]["setuptools"]["package-data"] + assert "py.typed" in package_data["failproofai_sdk"], ( + "py.typed exists on disk but is not in [tool.setuptools.package-data], so " + "it is absent from the built wheel — where it is the only thing that " + "makes the type hints count." + ) + + +def test_importing_the_package_does_not_touch_the_network_or_the_filesystem_eagerly(): + """Import must be cheap and side-effect-light beyond starting the flush thread. + + Constructing `EventWriter` at module scope already starts a daemon thread, + which is a documented cost. Creating directories or reading config at import + time would be a further one, paid by every process that imports the package + whether or not it emits anything. + """ + source = (PKG / "__init__.py").read_text(encoding="utf-8") + tree = ast.parse(source) + module_level_calls = [ + node + for node in tree.body + if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call) + ] + called = { + node.value.func.id + for node in module_level_calls + if isinstance(node.value.func, ast.Name) + } + assert called <= {"EventWriter", "EventNamespace"}, ( + f"__init__ gained module-level construction of {sorted(called - {'EventWriter', 'EventNamespace'})}. " + "Import-time work is paid by every process that imports this package." + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# The adapters exist, so laziness is now the load-bearing half +# ───────────────────────────────────────────────────────────────────────────── + + +def test_importing_the_package_loads_no_framework(): + """The promise, asserted at runtime instead of inferred from the source. + + `integrations/` may import frameworks; what must stay true is that nothing + reaches it unless the caller asks. A fresh interpreter is used because this + test process has already imported half of everything — checking `sys.modules` + in-process would pass on the strength of nobody having imported LangChain yet. + """ + import json + import subprocess + + probe = ( + "import json, sys; import failproofai_sdk; " + f"roots = {sorted(FRAMEWORK_ROOTS)!r}; " + "print(json.dumps(sorted({m.split('.')[0] for m in sys.modules} & set(roots))))" + ) + result = subprocess.run( + [sys.executable, "-c", probe], capture_output=True, text=True, cwd=str(ROOT), timeout=60 + ) + assert result.returncode == 0, result.stderr + loaded = json.loads(result.stdout.strip()) + assert loaded == [], ( + f"`import failproofai_sdk` pulled in {loaded}. The adapters must stay behind " + "`instrument()`, which resolves them by string at call time — an eager " + "import here is an ImportError in every process that has not installed " + "that framework." + ) + + +def test_the_adapter_registry_holds_strings_not_modules(): + """`_REGISTRY` maps a name to a dotted path; importing it here would defeat it.""" + from failproofai_sdk.integrations import _REGISTRY + + assert _REGISTRY, "the adapter registry is empty" + for name, target in _REGISTRY.items(): + assert isinstance(target, str), f"{name} maps to {type(target).__name__}, not a path" + assert target.startswith("failproofai_sdk.integrations."), target + + +def test_instrument_on_a_bare_interpreter_installs_nothing_and_does_not_raise(): + """The realistic first call: no framework installed, `instrument()` anyway. + + It must return an empty result rather than raising — an observability call + that explodes because the user has not installed LangChain is worse than one + that does nothing. + """ + import subprocess + + probe = ( + "import failproofai_sdk as f; r = f.instrument(); " + "assert r == () or r == [], r; print('ok')" + ) + result = subprocess.run( + [sys.executable, "-c", probe], capture_output=True, text=True, cwd=str(ROOT), timeout=60 + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "ok" + + +@pytest.mark.parametrize("extra", sorted(FRAMEWORK_EXTRAS), ids=sorted(FRAMEWORK_EXTRAS)) +def test_a_framework_extra_names_only_its_own_framework(extra): + """Each one pulls in a framework and nothing else. + + An extra that quietly added a runtime library would put it in the + environment of everyone who typed `failproofai-sdk[langchain]`, which is the + zero-dependency promise leaking out through a side door. + """ + manifest = tomllib.loads(PYPROJECT.read_text(encoding="utf-8")) + specs = manifest["project"]["optional-dependencies"][extra] + assert specs, f"the {extra} extra is empty" + assert {_named(s) for s in specs} == FRAMEWORK_EXTRAS[extra] + + +def test_no_extra_refers_back_to_this_package(): + """A self-referential extra is how a package installs something else over itself. + + On public PyPI `agenteye` is the CLI, which is what made this a real hazard + in the SDK's previous home: `agenteye[langchain]` would have pulled the CLI + in on top of the SDK. The name is different here; the shape of the mistake is + not, and there is deliberately no `[all]` either — an extra that installs + four agent frameworks at once is a resolver problem handed to somebody who + wanted a telemetry library. + """ + manifest = tomllib.loads(PYPROJECT.read_text(encoding="utf-8")) + extras = manifest["project"]["optional-dependencies"] + assert "all" not in extras + for name, specs in extras.items(): + for spec in specs: + assert _named(spec) not in {"failproofai-sdk", "failproofai_sdk", "agenteye"}, ( + f"the {name} extra refers back to this package: {spec!r}" + ) + + +def test_every_framework_extra_has_an_upper_bound(): + """A ceiling on each, or a clean build a year from now silently stops working. + + The adapters subclass framework callback bases. A new major shifts that API, + the adapter stops receiving events, and it raises nothing while doing so — + the failure is an empty dashboard, not a traceback. + """ + manifest = tomllib.loads(PYPROJECT.read_text(encoding="utf-8")) + extras = manifest["project"]["optional-dependencies"] + for name in FRAMEWORK_EXTRAS: + for spec in extras[name]: + assert "<" in spec, f"{name}: {spec!r} has no upper bound" diff --git a/sdk/python/uv.lock b/sdk/python/uv.lock new file mode 100644 index 000000000..c6427f215 --- /dev/null +++ b/sdk/python/uv.lock @@ -0,0 +1,6005 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version == '3.13.*'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'win32'", + "python_full_version < '3.11' and sys_platform != 'win32'", +] + +[[package]] +name = "aiofiles" +version = "24.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/4d/4a99fb425c5e0cad715eea7bd190aff46f38b959a0a2dadb993705d34b26/aiohttp-3.14.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b", size = 765848, upload-time = "2026-07-23T01:52:08.217Z" }, + { url = "https://files.pythonhosted.org/packages/74/e8/43b85dc55b8e950dc644babe762add781319ea881b57b33d2cce12017d12/aiohttp-3.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a", size = 517476, upload-time = "2026-07-23T01:52:10.846Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9e/73b582c4dbbc3c12ef4473822475effaabf1f934b56f14f5b03fe5d3a2af/aiohttp-3.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5", size = 515334, upload-time = "2026-07-23T01:52:12.636Z" }, + { url = "https://files.pythonhosted.org/packages/79/03/e98c3c9e05a5bdf97defe5ff9169baba4f0ec9a901f2d60e0f060c2f051e/aiohttp-3.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f", size = 1708830, upload-time = "2026-07-23T01:52:14.538Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2c/26e60b694844dfd2176c57f913a22d0cd6a16f9ff202cbda7580d0328b98/aiohttp-3.14.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43", size = 1674012, upload-time = "2026-07-23T01:52:16.486Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/672df92e3172cd876aacfa97a952ac560877eb169384b2991ac5b273de4c/aiohttp-3.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9", size = 1767015, upload-time = "2026-07-23T01:52:18.28Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c5/228dec7bfec1c373cc2217cdeb47d6456dcd7a13a4c55144930a75ae3851/aiohttp-3.14.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8", size = 1858700, upload-time = "2026-07-23T01:52:20.08Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ff/cb36724e8c8d17f90ada567a9ff3efe1d6e9b549fba697a242aece180f21/aiohttp-3.14.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479", size = 1714075, upload-time = "2026-07-23T01:52:22.071Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3a/296a4135c6366376263aeef54b15caca1f07676c2ae0c525d7832f2f808a/aiohttp-3.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b", size = 1588234, upload-time = "2026-07-23T01:52:23.757Z" }, + { url = "https://files.pythonhosted.org/packages/7d/81/9d5d853ef892dc066d1eb6db0e87a47348b920c1c879aa554612fdbd9d79/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d", size = 1677300, upload-time = "2026-07-23T01:52:25.861Z" }, + { url = "https://files.pythonhosted.org/packages/68/96/021d386ae32d9b26d4b88df2e794546232ff56bb6be952bf6be227c0bbc7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d", size = 1691501, upload-time = "2026-07-23T01:52:28Z" }, + { url = "https://files.pythonhosted.org/packages/29/9f/af66adce26a14af135c003cbd0f44ccaa68cebd30ff8ac99ca47fb4958f7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2", size = 1735113, upload-time = "2026-07-23T01:52:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/28c390d4c9851effe52ac25b5a2e1d92246acd00728b4fc7975dafb67484/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48", size = 1577486, upload-time = "2026-07-23T01:52:31.937Z" }, + { url = "https://files.pythonhosted.org/packages/db/c2/00e23a1bf2abb70dd353f6987db7e7f2491d0261f7363997738c71c98f95/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f", size = 1751353, upload-time = "2026-07-23T01:52:33.688Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7d/d51a706a8cbfa57f0611127daf61ab3ae02ab8420b0407412079227d1c65/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32", size = 1698681, upload-time = "2026-07-23T01:52:38.167Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b0/90bd5cd9fdd9787cb4211d284d1fb8401339a933cb0227a15b71e789232f/aiohttp-3.14.3-cp310-cp310-win32.whl", hash = "sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e", size = 456733, upload-time = "2026-07-23T01:52:41.823Z" }, + { url = "https://files.pythonhosted.org/packages/d8/15/fe5b8f6a71ae112bc677163d0b0701bda5dc15005249582258ede0eb88c7/aiohttp-3.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c", size = 480460, upload-time = "2026-07-23T01:52:43.905Z" }, + { url = "https://files.pythonhosted.org/packages/54/00/45e98b6645cd7f00a4b78b749ebd309094b0eaeb2d2e96157eadbc0d0050/aiohttp-3.14.3-cp310-cp310-win_arm64.whl", hash = "sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb", size = 453479, upload-time = "2026-07-23T01:52:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, + { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, + { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, + { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, + { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, + { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "aiosqlite" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/7d/8bca2bf9a247c2c5dfeec1d7a5f40db6518f88d314b8bca9da29670d2671/aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3", size = 13454, upload-time = "2025-02-03T07:30:16.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "appdirs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "banks" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "filetype" }, + { name = "griffe" }, + { name = "jinja2" }, + { name = "platformdirs" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/f0/ce5b3105a8551fdcedb509ab5340066247b3cd1b28e79f9296be2d6d3bf4/banks-2.5.0.tar.gz", hash = "sha256:fdd4fd54b84dbe31cb51a1173c960697c73d683a52fb0b1d1957a557a8d6fcc8", size = 194585, upload-time = "2026-08-08T15:54:16.548Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/9c/7dc7b15cecc03baa47d413b2599487b1fab270169b00feaa5e74978845f3/banks-2.5.0-py3-none-any.whl", hash = "sha256:8804c58f23e41a5aabe9ecfdf64235cdf1d5f3b16ad95ec2a54b6dc738dc1dfc", size = 38620, upload-time = "2026-08-08T15:54:15.505Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, + { url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" }, + { url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" }, +] + +[[package]] +name = "build" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10.2'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/e0/df5e171f685f82f37b12e1f208064e24244911079d7b767447d1af7e0d70/build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647", size = 89796, upload-time = "2026-04-30T03:18:25.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018, upload-time = "2026-04-30T03:18:23.644Z" }, +] + +[[package]] +name = "cel-python" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-re2" }, + { name = "jmespath" }, + { name = "lark" }, + { name = "pendulum" }, + { name = "pyyaml" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/4e/f821948a5bbd7a98a218720f831a62216f79a98e43b13d9ab2f98e37c5f8/cel_python-0.5.0.tar.gz", hash = "sha256:3eb0a619e8df0f338d0430cda01427a742e77e3c433a1c7c3ebd409cd804c45a", size = 13364027, upload-time = "2026-01-31T19:07:13.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/f8/38812adc3f787c2c2e8ba56f524185ed379656c10b40347a32796ba61c08/cel_python-0.5.0-py3-none-any.whl", hash = "sha256:d0f85008b89655c2bb18d797d2fa3f96f2ed80f4a3b43b0e8138c6646581e5f6", size = 84950, upload-time = "2026-01-31T19:07:11.821Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" }, + { url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" }, + { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" }, + { url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" }, + { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" }, + { url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa", size = 369072, upload-time = "2026-08-15T08:16:22.964Z" }, + { url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda", size = 251142, upload-time = "2026-08-15T08:16:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45", size = 240714, upload-time = "2026-08-15T08:16:26.392Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab", size = 279637, upload-time = "2026-08-15T08:16:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a", size = 276543, upload-time = "2026-08-15T08:16:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3", size = 261644, upload-time = "2026-08-15T08:16:30.722Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb", size = 259609, upload-time = "2026-08-15T08:16:32.248Z" }, + { url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f", size = 252457, upload-time = "2026-08-15T08:16:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea", size = 242240, upload-time = "2026-08-15T08:16:35.396Z" }, + { url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f", size = 280308, upload-time = "2026-08-15T08:16:36.825Z" }, + { url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182", size = 258679, upload-time = "2026-08-15T08:16:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa", size = 277221, upload-time = "2026-08-15T08:16:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818", size = 263799, upload-time = "2026-08-15T08:16:40.885Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20", size = 182037, upload-time = "2026-08-15T08:16:42.198Z" }, + { url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d", size = 206030, upload-time = "2026-08-15T08:16:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5", size = 185092, upload-time = "2026-08-15T08:16:45.177Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" }, + { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" }, + { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" }, + { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "chromadb" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bcrypt" }, + { name = "build" }, + { name = "grpcio" }, + { name = "httpx" }, + { name = "importlib-resources" }, + { name = "jsonschema" }, + { name = "kubernetes" }, + { name = "mmh3" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "onnxruntime" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-sdk" }, + { name = "orjson" }, + { name = "overrides" }, + { name = "posthog" }, + { name = "pybase64" }, + { name = "pydantic" }, + { name = "pypika" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "tenacity" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/48/11851dddeadad6abe36ee071fedc99b5bdd2c324df3afa8cb952ae02798b/chromadb-1.1.1.tar.gz", hash = "sha256:ebfce0122753e306a76f1e291d4ddaebe5f01b5979b97ae0bc80b1d4024ff223", size = 1338109, upload-time = "2025-10-05T02:49:14.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/59/0d881a9b7eb63d8d2446cf67fcbb53fb8ae34991759d2b6024a067e90a9a/chromadb-1.1.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:27fe0e25ef0f83fb09c30355ab084fe6f246808a7ea29e8c19e85cf45785b90d", size = 19175479, upload-time = "2025-10-05T02:49:12.525Z" }, + { url = "https://files.pythonhosted.org/packages/94/4f/5a9fa317c84c98e70af48f74b00aa25589626c03a0428b4381b2095f3d73/chromadb-1.1.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:95aed58869683f12e7dcbf68b039fe5f576dbe9d1b86b8f4d014c9d077ccafd2", size = 18267188, upload-time = "2025-10-05T02:49:09.236Z" }, + { url = "https://files.pythonhosted.org/packages/45/1a/02defe2f1c8d1daedb084bbe85f5b6083510a3ba192ed57797a3649a4310/chromadb-1.1.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06776dad41389a00e7d63d936c3a15c179d502becaf99f75745ee11b062c9b6a", size = 18855754, upload-time = "2025-10-05T02:49:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0d/80be82717e5dc19839af24558494811b6f2af2b261a8f21c51b872193b09/chromadb-1.1.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bba0096a7f5e975875ead23a91c0d41d977fbd3767f60d3305a011b0ace7afd3", size = 19893681, upload-time = "2025-10-05T02:49:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/2d/6e/956e62975305a4e31daf6114a73b3b0683a8f36f8d70b20aabd466770edb/chromadb-1.1.1-cp39-abi3-win_amd64.whl", hash = "sha256:a77aa026a73a18181fd89bbbdb86191c9a82fd42aa0b549ff18d8cae56394c8b", size = 19844042, upload-time = "2025-10-05T02:49:16.925Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[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 = "coloredlogs" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "humanfriendly" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, +] + +[[package]] +name = "crewai" +version = "1.15.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "aiosqlite" }, + { name = "appdirs" }, + { name = "cel-python" }, + { name = "chromadb" }, + { name = "click" }, + { name = "crewai-cli" }, + { name = "crewai-core" }, + { name = "httpx" }, + { name = "instructor" }, + { name = "json-repair" }, + { name = "json5" }, + { name = "jsonref" }, + { name = "lancedb" }, + { name = "mcp" }, + { name = "openai" }, + { name = "openpyxl" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "pdfplumber" }, + { name = "portalocker" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "tokenizers" }, + { name = "tomli" }, + { name = "tomli-w" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/80/616fbc703f3e682dd7009c247d70b2e185a139f86e48c0b5a66be8f63adf/crewai-1.15.16.tar.gz", hash = "sha256:38f75f499a5e6a5dc78369a3f5384452b791bc4852a1ff38c342aca4e04e8264", size = 7885470, upload-time = "2026-08-14T00:08:52.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/55/29fc1a03ae190c0bc2f921dcf9226c0afa22483558a18bb8c6f0cbd1679d/crewai-1.15.16-py3-none-any.whl", hash = "sha256:a93ae2c78b42dacdb932c2e4bbba2efb108ac610c23cf5453d8d59000858665b", size = 1114637, upload-time = "2026-08-14T00:08:49.411Z" }, +] + +[[package]] +name = "crewai-cli" +version = "1.15.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appdirs" }, + { name = "certifi" }, + { name = "click" }, + { name = "crewai-core" }, + { name = "cryptography" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "textual" }, + { name = "tomli" }, + { name = "tomli-w" }, + { name = "uv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/e4/eb56bf3c36485b6f6c51489d747d4f92750d3cadc19ecdb03c23dd026a74/crewai_cli-1.15.16.tar.gz", hash = "sha256:6d8f0c03bb05e88114b1a60e93aced09db2cc0a83f6811ec83f2e902fda14426", size = 227940, upload-time = "2026-08-14T00:08:56.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/b2/bbc5da7b20e15a5e67b92aed90a729a8e3175918adc626a763735d893d6a/crewai_cli-1.15.16-py3-none-any.whl", hash = "sha256:b98848f2cffe03fcefe69eeeb64e8dca8ec0fcc7f00437c419ad1972dd4a8c2c", size = 195493, upload-time = "2026-08-14T00:08:54.224Z" }, +] + +[[package]] +name = "crewai-core" +version = "1.15.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appdirs" }, + { name = "cryptography" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "packaging" }, + { name = "portalocker" }, + { name = "pydantic" }, + { name = "pyjwt" }, + { name = "rich" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/86/4d8273b80f41d7c7637ae0ada4d8ba78b540acf94735bdd0bf624ccb776d/crewai_core-1.15.16.tar.gz", hash = "sha256:c8d5548e223f32262243e91ee83b6976002616a26fc10951b3d5f1c05f10697f", size = 35445, upload-time = "2026-08-14T00:08:58.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/88/e954a1928421fd923b92c33cf22951d5e0dd2dc0c3fb129dd48dfeaa6b57/crewai_core-1.15.16-py3-none-any.whl", hash = "sha256:7a74e6ddbecd9df042dd459fd5e19a308b576958469bfa134ceb12dc6814106b", size = 40428, upload-time = "2026-08-14T00:08:57.231Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, +] + +[[package]] +name = "dataclasses-json" +version = "0.6.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marshmallow" }, + { name = "typing-inspect" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227, upload-time = "2024-06-09T16:20:19.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "deprecation" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, +] + +[[package]] +name = "dirtyjson" +version = "1.0.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/04/d24f6e645ad82ba0ef092fa17d9ef7a21953781663648a01c9371d9e8e98/dirtyjson-1.0.8.tar.gz", hash = "sha256:90ca4a18f3ff30ce849d100dcf4a003953c79d3a2348ef056f1d9c22231a25fd", size = 30782, upload-time = "2022-11-28T23:32:33.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/69/1bcf70f81de1b4a9f21b3a62ec0c83bdff991c88d6cc2267d02408457e88/dirtyjson-1.0.8-py3-none-any.whl", hash = "sha256:125e27248435a58acace26d5c2c4c11a1c0de0a9c5124c5a94ba78e517d74f53", size = 25197, upload-time = "2022-11-28T23:32:31.219Z" }, +] + +[[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 = "durationpy" +version = "0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "failproofai-sdk" +source = { editable = "." } + +[package.optional-dependencies] +crewai = [ + { name = "crewai" }, + { name = "onnxruntime", marker = "python_full_version < '3.11'" }, +] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +langchain = [ + { name = "langchain-core" }, +] +langgraph = [ + { name = "langgraph" }, +] +llamaindex = [ + { name = "llama-index-core" }, +] +pydantic-ai = [ + { name = "pydantic-ai-slim" }, +] + +[package.metadata] +requires-dist = [ + { name = "crewai", marker = "extra == 'crewai'", specifier = ">=1.13,<2" }, + { name = "langchain-core", marker = "extra == 'langchain'", specifier = ">=1.4.7,<2" }, + { name = "langgraph", marker = "extra == 'langgraph'", specifier = ">=1.2,<2" }, + { name = "llama-index-core", marker = "extra == 'llamaindex'", specifier = ">=0.14.23,<0.15" }, + { name = "onnxruntime", marker = "python_full_version < '3.11' and extra == 'crewai'", specifier = ">=1.14,<1.24" }, + { name = "pydantic-ai-slim", marker = "extra == 'pydantic-ai'", specifier = ">=2.0,<3" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3,<2" }, + { name = "tomli", marker = "python_full_version < '3.11' and extra == 'dev'", specifier = ">=2" }, +] +provides-extras = ["dev", "langchain", "langgraph", "crewai", "llamaindex", "pydantic-ai"] + +[[package]] +name = "filelock" +version = "3.32.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/64/a02e6765de08964ed371eca577870593245afc9dfac16d037de7c10d18e6/filelock-3.32.3.tar.gz", hash = "sha256:0ffa185a3540854c95caa7fa76b76cb219d907415e2c5dc9af25fd970563487f", size = 218135, upload-time = "2026-08-13T16:00:05.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl", hash = "sha256:7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09", size = 98901, upload-time = "2026-08-13T16:00:03.974Z" }, +] + +[[package]] +name = "filetype" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020, upload-time = "2022-11-02T17:34:04.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload-time = "2026-07-28T16:34:51.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, +] + +[[package]] +name = "genai-prices" +version = "0.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx2" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/14/a188df294f013ec9cd97fc6b145f5427f89067bfb2c260fc3fb5c8d1fb34/genai_prices-0.1.3.tar.gz", hash = "sha256:62c30cddd6c2d2199d878d1a70521c3e37347cd9394446d107dc774a78ed3780", size = 92638, upload-time = "2026-08-15T00:10:31.771Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/cd/d94b47c26d6367e0b949edfe2da5a47fb74037e799a0edd5b825e049f2b9/genai_prices-0.1.3-py3-none-any.whl", hash = "sha256:a2603841429c843da91c987d9ef598c73bd940caf44e844ab046d551791c04bb", size = 96892, upload-time = "2026-08-15T00:10:30.595Z" }, +] + +[[package]] +name = "google-re2" +version = "1.1.20251105" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/60/805c654ba53d685513df955ee745f71920fe8e6a284faf0f9b9dc19b659c/google_re2-1.1.20251105.tar.gz", hash = "sha256:1db14a292ee8303b91e91e7c37e05ac17d3c467f29416c79ac70a78be3e65bda", size = 11676, upload-time = "2025-11-05T14:58:07.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/fb/36548d5d791d2d750dc6fc2ab87fbe50f0bcc054673e1cf64928908892a3/google_re2-1.1.20251105-1-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:88bd426c1904f3562049bf766301bbc4f7a4bcb8f61e92f8cc833faac1cf2a92", size = 483062, upload-time = "2025-11-05T14:56:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5d/25afc138821a1958940ee4a9bc83a87b59a6dbedd7ef0db4ee04b572a3b0/google_re2-1.1.20251105-1-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:a486dc10bb07f3c34b9908541368e21ab6d77972569427200db077126668fbf3", size = 514075, upload-time = "2025-11-05T14:56:51.871Z" }, + { url = "https://files.pythonhosted.org/packages/70/00/5303bb660b6f75a71f75dc818a35082c30508d4dd5477891f13e831f39e8/google_re2-1.1.20251105-1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:a9aa02dc1345f0889c6ce1365d5f93d5b161b512f4c6df3cfadf3298493fb678", size = 484069, upload-time = "2025-11-05T14:56:53.479Z" }, + { url = "https://files.pythonhosted.org/packages/55/d3/8d11005db3000128055f6d3868a3216dd639721040eb988b3eccce852bc0/google_re2-1.1.20251105-1-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:032160ad8c05739370813bcb15099854cd50faa933e0fe9607a2380659c750df", size = 515556, upload-time = "2025-11-05T14:56:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/21/36/c7d3c8dd7578badb53b929f5c8cc78bbbec23163029a15fdce2dfabf78f4/google_re2-1.1.20251105-1-cp310-cp310-macosx_15_0_arm64.whl", hash = "sha256:39a7013477c8778b1ddcc0d43eff0ee4a0f66b76c9db21f9e7b7d1f74852633f", size = 481738, upload-time = "2025-11-05T14:56:56.429Z" }, + { url = "https://files.pythonhosted.org/packages/61/c3/2199a9edefa1ffea59e5e54ebca34a126e0a2c5b4b2c73db9c5b97b9895d/google_re2-1.1.20251105-1-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:f886c88d56233483c5fd5ed1234e7e72389b8331250100983443fa30855deb63", size = 507751, upload-time = "2025-11-05T14:56:58.035Z" }, + { url = "https://files.pythonhosted.org/packages/28/34/e9a9fa5fd3b309c76262fd8642346b62235f7a9b7590563403ef427a366b/google_re2-1.1.20251105-1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8beddf48857fd3767c553f0be7414a7a483f9b6374c91c02474a616fc7f5c5b3", size = 572738, upload-time = "2025-11-05T14:56:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/65/d3/4aad2f11e635709c326a1c34bff59c879dab5c2ff720dbcd275c61c3ea56/google_re2-1.1.20251105-1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a319dcb37b069d72d968862335197f460803b3a35f99445ea805f69fac58759", size = 588959, upload-time = "2025-11-05T14:57:00.675Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d7/ce78b34800b966fc7c4abf2f40e71ece39c1485b57a283bcffae054a5aa3/google_re2-1.1.20251105-1-cp310-cp310-win32.whl", hash = "sha256:420fe037ad77ab3d1a280c6823985b89160896f66ce601a3923d020690a1f9b4", size = 432828, upload-time = "2025-11-05T14:57:01.985Z" }, + { url = "https://files.pythonhosted.org/packages/1b/4e/d381ebce2d14b381379485845f884d8c7b491196fed62c68932a4e5fef69/google_re2-1.1.20251105-1-cp310-cp310-win_amd64.whl", hash = "sha256:462dfcf147d0f54d0c93a69c361225119a4987c3b0ecd77f0e21ad9ba8bf180e", size = 490179, upload-time = "2025-11-05T14:57:03.278Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4d/203a08dab1bdb5c83b46dd424c01a789ecb5a37dbc80f33d016bd116a9d7/google_re2-1.1.20251105-1-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:329efa209ea7baa44f0facf0402fa34e655dc97fdeb10d0b83fc06354f5575fd", size = 483717, upload-time = "2025-11-05T14:57:04.808Z" }, + { url = "https://files.pythonhosted.org/packages/78/88/466026b43ff5c7d740f5ede090992ec63b60d1810ab14fe35dfc00677e0a/google_re2-1.1.20251105-1-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:aa2ad5f6f48921ec137a7b7f1b1da903ddef8627a2dc30bc878a9a69d9925719", size = 515547, upload-time = "2025-11-05T14:57:06.013Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6a/c6c9fdb00c98990e4f7a6cd650e209d7b5d2754ca0404b72c69ac9909a69/google_re2-1.1.20251105-1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ac1cb2526cc88f050a0661fc7245ad009ee454bddc541b2e653f1d007585000d", size = 485396, upload-time = "2025-11-05T14:57:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f6/529c44f607c47f96cfa29c1fe3a690fe75b2fdb48e9b0d6b54e5f0a75e59/google_re2-1.1.20251105-1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:50c7205182ad66c23c07abe8072f720ca2f7d595b61e28fd9b63623614f9afd6", size = 517150, upload-time = "2025-11-05T14:57:09.376Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/ccc07860e31ab81965c63f9ed4eb69ea0d3449a9b4e1610f71883694bbe8/google_re2-1.1.20251105-1-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:4cb5acee61e35772503b8b1db3c592a46b8e6a9bc0ab54d7d6233654ea2bf93d", size = 482807, upload-time = "2025-11-05T14:57:11.057Z" }, + { url = "https://files.pythonhosted.org/packages/bd/43/5fb20d16664457f61670bdd95f39039d43ee8b7732511c688e2f322a4317/google_re2-1.1.20251105-1-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:1617097d63620c2d46bdfc0e48f24f66cd341664fc75718636d234f67473fe7f", size = 508839, upload-time = "2025-11-05T14:57:12.338Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f2/6e470338271e164dd3c5e508876f99aec3ed23bf419c7d54a5672fd5b05f/google_re2-1.1.20251105-1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a5610b26742b90cb1d64ead2b16fe0e3bd7e67add03fd3779cd1b85e401661", size = 573718, upload-time = "2025-11-05T14:57:13.635Z" }, + { url = "https://files.pythonhosted.org/packages/91/21/4566fc344c21cf3c49082d13ddab785994b5e3b8b7fd4631242538f698a2/google_re2-1.1.20251105-1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03156291269f145eccddff63118f2df02d395792f51fc039f09955818943815a", size = 590749, upload-time = "2025-11-05T14:57:14.864Z" }, + { url = "https://files.pythonhosted.org/packages/94/19/5981fb798bb8d08933b815b1fd9e55d179c380b9d8c21a49197b9b7c5967/google_re2-1.1.20251105-1-cp311-cp311-win32.whl", hash = "sha256:54f51762b51dc238eceddf49b56cc2b64594fe72d9328c1c39d615aa990e1f87", size = 434066, upload-time = "2025-11-05T14:57:16.22Z" }, + { url = "https://files.pythonhosted.org/packages/49/e5/f83053a36cfc4762d843748e4f7a9c1141937dcf74cd6fc3f4598292dda3/google_re2-1.1.20251105-1-cp311-cp311-win_amd64.whl", hash = "sha256:f5f856ff5036a8f22b3bad57f376d4e3b97b59b64f311bdb1f83c8dabded2492", size = 491025, upload-time = "2025-11-05T14:57:17.746Z" }, + { url = "https://files.pythonhosted.org/packages/56/be/4315c3b38f42f9a2888fa76260545c98547502f1c35aa63a672d39011b2e/google_re2-1.1.20251105-1-cp311-cp311-win_arm64.whl", hash = "sha256:913864f97de4151eaa8bb7746ca230fd193656501e07fb658ce2cd46d4f6efcc", size = 642194, upload-time = "2025-11-05T14:57:19.374Z" }, + { url = "https://files.pythonhosted.org/packages/67/20/73b487538e9107c2fd96aed737e3f3890dfce3e292622e4ffb2f9c810ee5/google_re2-1.1.20251105-1-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b30f09b4d63249c72e65ccae4cbf6b331b48c22fc7cb439f1d85f347b9d07ceb", size = 485591, upload-time = "2025-11-05T14:57:20.961Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9a/ca3a993bdb5dc6d5b2616b9657b2872a83d1827f8bd3ab50cd629eb751c7/google_re2-1.1.20251105-1-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:9a77892c524b8bdf3d47d7cad1cc2ac3a0108bdd65007ef4c02888fa46baf8ee", size = 518780, upload-time = "2025-11-05T14:57:22.18Z" }, + { url = "https://files.pythonhosted.org/packages/df/37/b2e367987371514253ec9e514637f457deaacb7acc1c900814f3a6421e0f/google_re2-1.1.20251105-1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a3ac51b28cbf25c100dfd8849212d878d7005d1d4a7e129a10789043c56b6021", size = 486966, upload-time = "2025-11-05T14:57:24.575Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/1db6742943c0ac254bfb7d8a37a5d3f73f016a65cfa1f84fe3a0451820f6/google_re2-1.1.20251105-1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:9f7158afc9825ac2654c6561aea94a1f7edb5b5b88e6e3639bb80bb817d102ac", size = 520225, upload-time = "2025-11-05T14:57:26.039Z" }, + { url = "https://files.pythonhosted.org/packages/f4/0a/0747c92dbebe2c09a26bd7386d372b5c5a9926236b4f3d69bb8f15db05cb/google_re2-1.1.20251105-1-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:5320da07dc3b7ac7f407514f42ac17d67e771ac7c7562d449571185e6fb601b2", size = 482943, upload-time = "2025-11-05T14:57:27.353Z" }, + { url = "https://files.pythonhosted.org/packages/7f/14/6bfc6838bb6cb561824ac03deeab2bd11d5d9a93505f536c8fa2f6bd46c4/google_re2-1.1.20251105-1-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:5a4e5785bc30d52ce655d805b07ad2d8a4905429a5f690ae9c2f1caa76665709", size = 510384, upload-time = "2025-11-05T14:57:29.139Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/6add090c917ee39f6f0be753037cafceb3bad904b424efc155fb38082635/google_re2-1.1.20251105-1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b7a3b90f747130310d4b3b8e19ebb845d0d97c1deb63b36f76c7242dacbd736", size = 572446, upload-time = "2025-11-05T14:57:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1c/8b1ccbeade96a21435d55b5185cd6d9b2ceab5a9af998a4d9099e0540759/google_re2-1.1.20251105-1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:809c5fa5d08279413b29c2e2c5c528e85cd94a0e0fd897db595a0c09eeee2782", size = 591348, upload-time = "2025-11-05T14:57:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/62/cf/7bdd7a1ae7828b613011da808eafec4da3132f43c3be6af5e0bd670ebe8b/google_re2-1.1.20251105-1-cp312-cp312-win32.whl", hash = "sha256:d8424e63a9ec0fe5bde03d97876b2431f8a746af33eb475fa1ae39144bd05b2a", size = 433787, upload-time = "2025-11-05T14:57:33.071Z" }, + { url = "https://files.pythonhosted.org/packages/31/e9/5dd951c35acaabfe87c67228b9af2cdcd7779d9167edbe6b9094b8a8e529/google_re2-1.1.20251105-1-cp312-cp312-win_amd64.whl", hash = "sha256:062313c309f93dfeb6966372f4c446580e98879133ec155522eea8aaf568a5cd", size = 491726, upload-time = "2025-11-05T14:57:34.39Z" }, + { url = "https://files.pythonhosted.org/packages/60/8d/c1afd29fc2cb475fd4c634f3d3c8099c0efb662362c10b27a9eaf11c9357/google_re2-1.1.20251105-1-cp312-cp312-win_arm64.whl", hash = "sha256:558f144b26a9555ae4e9467cc3aa3299a8ce13217f328b21ae326ca0633be19b", size = 642673, upload-time = "2025-11-05T14:57:35.693Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/c441722196598fc3de0f654606ad9975a968c71dc27f516b5a4c9ebb94fd/google_re2-1.1.20251105-1-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:9f3cf610e857a7d6f02916cf2b7fc159a5429b8bcb23164500d46e5e233f2924", size = 485549, upload-time = "2025-11-05T14:57:36.939Z" }, + { url = "https://files.pythonhosted.org/packages/ea/87/cf588255e5ada1dfb555cc96de35be78438bb0b6faba64df5fe91cecc224/google_re2-1.1.20251105-1-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:a21c2807bf4d5d00f206a4ecb3b043aad674e28c451b697b740280f608872078", size = 518840, upload-time = "2025-11-05T14:57:38.115Z" }, + { url = "https://files.pythonhosted.org/packages/0d/39/da66e4ca9be0c51546efc6fb39cf1683c4be8245d8199cb54a9808e8d5fa/google_re2-1.1.20251105-1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8314144eefeee7b88b742081c2038418f677e63901039ca9dbfbc0c5bb6d2911", size = 487037, upload-time = "2025-11-05T14:57:39.467Z" }, + { url = "https://files.pythonhosted.org/packages/75/dd/24ba65692dd58dca6ff178428551f4e9b776d1489a1251f5c8539e598baa/google_re2-1.1.20251105-1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:28a46be978e53c772139d0f5c9ba69f53563fcdd4225407e4d34d51208b828f1", size = 520285, upload-time = "2025-11-05T14:57:40.666Z" }, + { url = "https://files.pythonhosted.org/packages/61/12/cfdbb92bed24af6474970a75a26145c424f98cfbcc633fdd185985f0efe0/google_re2-1.1.20251105-1-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:83292e23963aa1b219d5f64a65365b0880448a6a060276027b55270bc5b18c7e", size = 482981, upload-time = "2025-11-05T14:57:41.928Z" }, + { url = "https://files.pythonhosted.org/packages/97/bf/5fc32ded9279e69a87b88d7261e7e77e2e26325d4e27ca1303a3215e430a/google_re2-1.1.20251105-1-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:1920b15dc9b1bdfeca5aa2c60900373c6f27cd1056d53cd299456ea5540a6fff", size = 510366, upload-time = "2025-11-05T14:57:43.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/71/f927ddc7aef1b8d7ccc8a649c335d311f29f3dea658209e30e37720e4891/google_re2-1.1.20251105-1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b1458d9ca588124cd61aa1bf5388a216e1247e7d474f8e5e1530498044f5c87", size = 572390, upload-time = "2025-11-05T14:57:44.422Z" }, + { url = "https://files.pythonhosted.org/packages/f0/8c/23075e589038284c9487f41cde531d35873f9da622fb4ac7d1d97bd9086e/google_re2-1.1.20251105-1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a52cb204e49d20cdbb66faf394d57f476e96c39c23a328442ab0194fc6bd1a2b", size = 591386, upload-time = "2025-11-05T14:57:45.713Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7f/858453ef689f6b9895cd02b466836a9d1a6e4ba535d1a275b01bf73baa1d/google_re2-1.1.20251105-1-cp313-cp313-win32.whl", hash = "sha256:67c5c73d7ebcf3f0e0a3b528b41bd8c6c04900f1598aebf05bbdf15a06cf5f9a", size = 433807, upload-time = "2025-11-05T14:57:46.92Z" }, + { url = "https://files.pythonhosted.org/packages/08/24/6ea87fe682e115ffd296e91eb5c5a266349d1ee8414ce8ece3f99ec1ac84/google_re2-1.1.20251105-1-cp313-cp313-win_amd64.whl", hash = "sha256:0bcba63ad3ea8926fb0c71bb5044e33d405bb9395f5b5444393cd5f28f0bf6d3", size = 491734, upload-time = "2025-11-05T14:57:48.304Z" }, + { url = "https://files.pythonhosted.org/packages/34/85/32ba71b06f3cf5f9856ae95b3d6463b971742453631a5ae2c5be338ea377/google_re2-1.1.20251105-1-cp313-cp313-win_arm64.whl", hash = "sha256:64ee189ea857f2126c5e42073cfa9b03e9f4cbaf073edbedb575059074841aa0", size = 642654, upload-time = "2025-11-05T14:57:49.602Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7f/7eb238bdcd06182b5f427afd305cf413b7cf4ea71047308bbf35912cf923/google_re2-1.1.20251105-1-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:cc151cf6a585d9ebe711da32b23683fcff40f78db8c8587c7f4b209ef4658809", size = 484719, upload-time = "2025-11-05T14:57:51.326Z" }, + { url = "https://files.pythonhosted.org/packages/6d/62/eed28eab67f939f4b9383c47b1db11638ade6ac30785c15cb960de85ba43/google_re2-1.1.20251105-1-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:7e2186d2c90488c1e11895343941f35ca2f58e9ba6c6b034fd531abe22ef77cc", size = 517698, upload-time = "2025-11-05T14:57:52.597Z" }, + { url = "https://files.pythonhosted.org/packages/f7/16/a1e6768513f788bf9c67a1cfe379ef34a793983eee46e4b653e42b558b78/google_re2-1.1.20251105-1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:41be22359c3dceb582937739b4365dd8e279de24ad0a5b10e653503abaff2ed7", size = 486421, upload-time = "2025-11-05T14:57:53.852Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fc/7a97ffd36d451e5a8bfaff2f9022b14807795d588f98227ff96e8da99856/google_re2-1.1.20251105-1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f3168d7bbac247c862ea85b2f3c011d3a04bedcb6892b37f14d488f4133b206e", size = 519037, upload-time = "2025-11-05T14:57:55.078Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ee/8b6f7d94bb689dafdf60de8dd8f8f6296ad40d4d15c933fcda4da7a3a06b/google_re2-1.1.20251105-1-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:79ce664038194a31bbcf422137f9607ae3d9946a5cff98cf0efbeb7f9411e64b", size = 483373, upload-time = "2025-11-05T14:57:56.297Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a6/16a09e03d1de128f821869e4252688c21319f5017d9209f4d0e71ea5c951/google_re2-1.1.20251105-1-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:0476b07421b8882b279d5ceb5b760c15c62d581ded95274697fc1227e3869ee6", size = 510167, upload-time = "2025-11-05T14:57:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/c4/9d/213dce5de401527369fb5af11096b18c06001d9eb71f3318fe5eba1ec706/google_re2-1.1.20251105-1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85feec3161ffdc12f6b144e37a2f91f80b771c72ffadde60191e89a49f6d7e81", size = 573176, upload-time = "2025-11-05T14:57:59.211Z" }, + { url = "https://files.pythonhosted.org/packages/03/be/a8def96aa4a80b233e105767d22e3de961dcde5a04f0a05cb4f3ddb4df78/google_re2-1.1.20251105-1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7bfaa2cf55daf0c5c650e68526bb20b61e37d7f3ae53f6893013acc1c91c116", size = 591483, upload-time = "2025-11-05T14:58:00.416Z" }, + { url = "https://files.pythonhosted.org/packages/14/ea/144bbc4b9359da89aec07b4c2a91a6bfe7119914885386577c665b07bb01/google_re2-1.1.20251105-1-cp314-cp314-win32.whl", hash = "sha256:214c1accdc60fff9ce1bf812b157147ca361844f496ed9e0d5f357b0e562ced8", size = 433773, upload-time = "2025-11-05T14:58:01.594Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/74e301211699f1b650ba7690a3e4e52146ac4266fcd62f3ea0a945b9eda4/google_re2-1.1.20251105-1-cp314-cp314-win_amd64.whl", hash = "sha256:6d4d5fdadd329a2ed193463899d00ef2fd126172f36a4c01c9def271f19801b6", size = 491893, upload-time = "2025-11-05T14:58:02.969Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d1/4adcfcb9c95e3d064c9f7aaf6cb3a4fc842d86115014b9d4094db4d465b5/google_re2-1.1.20251105-1-cp314-cp314-win_arm64.whl", hash = "sha256:1d27f3a2a947ec1f721d0f14f661108acfd4f4d34f357ce28db951cc036656e5", size = 643093, upload-time = "2025-11-05T14:58:05.761Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/73/74bcab964c9a7a61f2bb71e8179b0f13e6fa98f7ce00fd168aab291e4a2e/googleapis_common_protos-1.75.1.tar.gz", hash = "sha256:d3042c6c5a2d4e67113104d6b6818b59b6bd92a197f2a91508e801fe815cf071", size = 150967, upload-time = "2026-08-06T06:24:51.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/51/186c02b8549b69ccda44429cf6ff5081e4b61a602ddfe6a8020d1be31d1b/googleapis_common_protos-1.75.1-py3-none-any.whl", hash = "sha256:28a1934bcd33b9c9da66ac301a0a4227e3367f095a17d0375cb98f0a09d93b79", size = 300626, upload-time = "2026-08-06T06:23:46.696Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/67/07ecd6d85c0f253363cbc4ac9e7dd048ca571a267fbccfea084d6009ac3b/greenlet-3.5.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:816230f469381ad0a43abc9fa8dda5a699e32fb78958dde32ded93213b70a667", size = 292971, upload-time = "2026-08-10T13:28:09.837Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/426733bc24247ee17bb76df90f550c299ff5f8572b2bdd7cacb5c53fd994/greenlet-3.5.5-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5433cf291e0ef9114bd14d0d824db6e5e4a43033234bca48181a9597acca07b", size = 609290, upload-time = "2026-08-10T14:14:32.273Z" }, + { url = "https://files.pythonhosted.org/packages/9c/dc/17d3a5acceb2fd0bbc9682a228117b719cdfb68085e6dbb33fa325755f27/greenlet-3.5.5-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:19d59f068887d8c5907fc177f27683413ace3011b6ed646c0b309266e74a6502", size = 622650, upload-time = "2026-08-10T14:27:22.004Z" }, + { url = "https://files.pythonhosted.org/packages/74/3f/b31e6bd6adb8f80492efb6a47e8f9cd0e7335db5aac2795b1876d5afcfee/greenlet-3.5.5-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:86c5113d698cb8d927b2750bb1f1d59eefe3a37e0e0217491aee29a7f84ef52c", size = 629560, upload-time = "2026-08-10T14:30:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/51/91/00b3c0566316c6f383ea16ee05388ec4f57f6e0afa49e72ae471eda8c47f/greenlet-3.5.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff00e12102358292087274dfb1669132387ff6e7920ebf9d85f4826ce0d3a56", size = 622819, upload-time = "2026-08-10T13:40:46.562Z" }, + { url = "https://files.pythonhosted.org/packages/18/5e/58c561efde575c4ca070030e2e0513e8d1b07b76d8a2d84a9bcef1becd42/greenlet-3.5.5-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:c69bed34470abfcd456984fdadaa18e62169af4480335c45f3c32d1d9c12e638", size = 425489, upload-time = "2026-08-10T14:29:59.768Z" }, + { url = "https://files.pythonhosted.org/packages/3c/78/52de4f7ac9152ad1dbc8f437895c1e573d30ee1e1427e0f91a280e2417b6/greenlet-3.5.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:523bb8e27614d77101ea7a8cf59f8d91219b72d5c29f6a038c92b50828bfa8d0", size = 1582164, upload-time = "2026-08-10T14:15:02.645Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ff/c3855a00c2417e8f61c7b9f0bc4f8f599c6928f5c15681ad251e78a91e05/greenlet-3.5.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1e2db190db51c17433eee424803818cf0670bf049d9cfe0dd07be111d1aa7c4", size = 1648807, upload-time = "2026-08-10T13:40:27.471Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/d29ff6a79dbd1ec1fe74c155d25d4c5a85e221d6b9ffc2cc7a709e7c8c39/greenlet-3.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:740e544169527b82695ce76af2f7ad6f030904658f2f3921a1d245771fb88cfc", size = 322832, upload-time = "2026-08-10T13:28:10.85Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a3/07297917485ee2ca85bc3c8dc6ed85ad3fffcf424047fba62671dba68e97/greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61", size = 294165, upload-time = "2026-08-10T13:25:17.987Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/6f732f9314cda54c5fd48a7620c7160f4f286967e8045ad94b9d66ce80b7/greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c", size = 613610, upload-time = "2026-08-10T14:14:33.829Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c0/b27589e25d220289edcd4d582b2b17b83058d1a56d53d971b6ea1a34f10d/greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da", size = 625481, upload-time = "2026-08-10T14:27:23.647Z" }, + { url = "https://files.pythonhosted.org/packages/39/82/5c873dbb4fb001d22fbbd50e80d4c1b0181ddae106856132160f84b94e88/greenlet-3.5.5-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:abc8bc8d9f935cd685457545b6a53863a877fdc12c2c0f5ee9beee18d9db139c", size = 633329, upload-time = "2026-08-10T14:30:06.062Z" }, + { url = "https://files.pythonhosted.org/packages/51/2d/f2c928218ac52f26d7a2c188c171d1b7e728b23782cb3347e7b4fce1493a/greenlet-3.5.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864", size = 624562, upload-time = "2026-08-10T13:40:48.064Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/f7df98e72a8eb4a7edfec5a08d6d1a4ab53a52c95ba0b2ea6c10b8dd9bd0/greenlet-3.5.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:3134291427bb0f3526e9d90311988caf336eb43730e95244997a4fb15f45144f", size = 428145, upload-time = "2026-08-10T14:30:01.071Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4a/92fc51d5d35912f4f06eec037ba347985defd0be47463a010a325634d9d2/greenlet-3.5.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db", size = 1584909, upload-time = "2026-08-10T14:15:04.343Z" }, + { url = "https://files.pythonhosted.org/packages/ac/58/ed98b80ac5738c149a5258544843c45601ade1fd70f61740cdaead6351b3/greenlet-3.5.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39", size = 1651184, upload-time = "2026-08-10T13:40:28.879Z" }, + { url = "https://files.pythonhosted.org/packages/d8/be/b582ceb80cefdf9d8da34078714e4b12b3d16f509dee0f65e40a5cc8fc7d/greenlet-3.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53", size = 323280, upload-time = "2026-08-10T13:26:07.495Z" }, + { url = "https://files.pythonhosted.org/packages/4d/18/5313c4c58598c38b0373c013e4ff2b3e6d258aaaa338f373335ebecdaddd/greenlet-3.5.5-cp311-cp311-win_arm64.whl", hash = "sha256:2b70a766135540c472ac1393d57c2e1b4a2eb85bf526a1e41e6d096173a8cee5", size = 307785, upload-time = "2026-08-10T13:28:34.874Z" }, + { url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/9b/30/87c212b5c684d0e72974f1063b7a9687631e8985902c06e1016542c874e7/greenlet-3.5.5-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f", size = 629504, upload-time = "2026-08-10T14:30:07.967Z" }, + { url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439, upload-time = "2026-08-10T13:40:49.391Z" }, + { url = "https://files.pythonhosted.org/packages/63/2c/eb487fafc9f50ffff2b1e0b697f70fb34bf150821c08ab225aacf5583a7e/greenlet-3.5.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759", size = 432462, upload-time = "2026-08-10T14:30:02.309Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342, upload-time = "2026-08-10T14:15:05.653Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744, upload-time = "2026-08-10T13:40:30.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171, upload-time = "2026-08-10T13:28:44.472Z" }, + { url = "https://files.pythonhosted.org/packages/76/e5/4dee4d8d2e603fe5fdd7b444e63219f7b9bd852c60c6214511c7157cbe88/greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52", size = 308362, upload-time = "2026-08-10T13:26:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" }, + { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/eaa476d6bf3816828d0d70e80dcc36bf30a058233bd889e707e693f6e860/greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42", size = 632726, upload-time = "2026-08-10T14:30:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/0cc2849ede68579291e9c59b3ab6ec1958f98681cca5b14d8fc75bf674a4/greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b", size = 434966, upload-time = "2026-08-10T14:30:03.729Z" }, + { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/78/649cb5c09d4d81f6dd1444e75474a7206784743283a21d24171562ac4899/greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc", size = 308260, upload-time = "2026-08-10T13:27:50.795Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, + { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/eb/52/f005d579acde46c3d1cc3cab1c9f3d5708c8a3006a4120e8cf5da801afe9/greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8", size = 677863, upload-time = "2026-08-10T14:30:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8a/a75f8a2bdcef3c358a3147cdc9db3aa83755f0a038f766ab0bedb66f512c/greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53", size = 480554, upload-time = "2026-08-10T14:30:05.171Z" }, + { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, + { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, + { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ac/0d7887aa4bbfc9eba075cc428244dfc96f623478454d5ec81180d0d6bd5a/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387", size = 681587, upload-time = "2026-08-10T14:30:13.519Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/4f/18/8d58ba1c429b0383e3219a3d0e0bba241d0444d8ed05b73349953c7d7c7b/greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41", size = 510175, upload-time = "2026-08-10T14:30:07.047Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" }, + { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" }, + { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" }, + { url = "https://files.pythonhosted.org/packages/65/53/4e13642efc4d7ad6554ecb2242a5be42666b2e1a067323e88dfc0124a04b/greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76", size = 681436, upload-time = "2026-08-10T14:30:14.839Z" }, + { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" }, + { url = "https://files.pythonhosted.org/packages/cd/32/188447c9a468d6977d2989397226b0c6b65ab6f4cf943f931643328512fc/greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474", size = 487404, upload-time = "2026-08-10T14:30:08.903Z" }, + { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" }, + { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" }, + { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f4/e450a68a152f819491d8c7df6a8254e761d87e6a78759268961f8c5bd4dd/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1", size = 686022, upload-time = "2026-08-10T14:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/17e63d6bf3b9c9b9dbea981b7f643a71f79603bdfb4f1c3a9cf353e22aed/greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f", size = 516951, upload-time = "2026-08-10T14:30:10.907Z" }, + { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" }, + { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" }, +] + +[[package]] +name = "griffe" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffecli" }, + { name = "griffelib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/a0/927cf9416f527f623f4ccd9220337fca0cd85303b7e82adeab1e53669cd3/griffe-2.2.0.tar.gz", hash = "sha256:9c0dd9a7feda9e169d783507d777c6fb2f2fc2919b139747a5b4f5357517a611", size = 246229, upload-time = "2026-08-16T14:04:57.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/51/c9f9cb144e2bd21cbcb6ebd7978ecb5b9f5a375e78313744158338c8039c/griffe-2.2.0-py3-none-any.whl", hash = "sha256:db20672b31c3de2a1af5a18a3b3d8c186ccc2425e10290486b4f0364d25929e9", size = 5070, upload-time = "2026-08-16T14:04:51.971Z" }, +] + +[[package]] +name = "griffecli" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "griffelib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/75/3cf2fb1ae21fb55a3b917b45d24426a620ee916f4bdbd03f350c69d3d892/griffecli-2.2.0.tar.gz", hash = "sha256:b9e763131218eb19887ed52de20fe944252d811bfb4a3e339874908a588f0044", size = 58066, upload-time = "2026-08-16T14:04:55.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/83/04652213d82afeecc8e8a31231eeb87cc484dcc055fd96ecb0bfd9860405/griffecli-2.2.0-py3-none-any.whl", hash = "sha256:8ebb7ae1dcc2617f5d39f00e4c238dd6b5cd0b13157b0ce22c1ea4ca8b4c5d1d", size = 11480, upload-time = "2026-08-16T14:04:53.14Z" }, +] + +[[package]] +name = "griffelib" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/b4/a767e91c606deefc447a96eaf59edd77397960b1d677dffd833ee8449831/griffelib-2.2.0.tar.gz", hash = "sha256:e1bc36fe9cd21d4b6b659b456346755e4cfdc5676c0a5214083126ee12612b3c", size = 227048, upload-time = "2026-08-16T14:04:58.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/b6/f65ac785d4ac90dcf7c831ac6256f5dd4a19780f4e1575b2c0d6eeebe319/griffelib-2.2.0-py3-none-any.whl", hash = "sha256:d71c3bc2bbed9f958488634fe788b843a9f705d6d2838ca32cd6c25eeb64dfc4", size = 166779, upload-time = "2026-08-16T14:04:54.365Z" }, +] + +[[package]] +name = "grpcio" +version = "1.83.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/fd/655c8a773d728bc3c93fb4713ae4bf79ffc75996f86fb78b2974c8e1dfbd/grpcio-1.83.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:fba099b716e73512d61b97f71ea3c31a72abb36904036e316bf4dd148ca8dcc8", size = 6334247, upload-time = "2026-07-23T15:18:53.099Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9a/1ce5760d35a04a992006dd2f79afff2db548f93ee7426fa95c9f1fc90c61/grpcio-1.83.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6755ed67cc3e454d51ae9f6e1915b80d3942fa4de956ef48dacd45ab7f40b727", size = 12168650, upload-time = "2026-07-23T15:18:56.348Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ab/bbcb5be0a1a6cb21f036e2afdd4f7a70147cfb7a7b42648a310d7c43acfc/grpcio-1.83.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5882c1a721b50ce0123ee5e839e1ab059ad72a7ade76cdf2d5bd833b56791acf", size = 6916899, upload-time = "2026-07-23T15:18:58.339Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/4c27977ecb3b3f9f363b93f570e001cb24ef264a9a907d7fd0f949ed59f0/grpcio-1.83.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4e3eedfc92b6b9f2960115e7e620cf0cbf80bb7849a51ce3820dc54dfd88b6b9", size = 7648761, upload-time = "2026-07-23T15:19:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/23/49/0c823a7627ff2e69a61e4a53c4edf215272892fc2c47c6431f033d46f4cc/grpcio-1.83.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4fcaa7c45c45b4a89e2867d1f1785d9481a788399d915e341ed2eb49aeef9dd4", size = 7074920, upload-time = "2026-07-23T15:19:02.293Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ce/963f01ff7c789a76909c9691b704112e02ca1e11c10405cd99c2bd7c40f1/grpcio-1.83.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6b6c666a1d5613ff360c9e90f44665e3a88b25a815209ddbc0917eec281931cb", size = 7598046, upload-time = "2026-07-23T15:19:03.921Z" }, + { url = "https://files.pythonhosted.org/packages/eb/de/1ce6bdefc847a7973040d10cebc8996c653a2a687c0a4da8d05dcab4e397/grpcio-1.83.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6be5c807b717be3dd649446f021301fd7907e376318675d2147823071034112a", size = 8634792, upload-time = "2026-07-23T15:19:05.633Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8b/7fe6a73895e3bdd788101d1276e48e0d262ebb165afacec1ec4efebcd785/grpcio-1.83.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c834e86d8fd2f03d7e4db49a027f7c5b89c5b88eed305543a5295bd6fee61e40", size = 8000286, upload-time = "2026-07-23T15:19:07.739Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b0/9a779de2bcda8722501a056fad1bec3d1117977af0c080ab1fc0655fdf35/grpcio-1.83.0-cp310-cp310-win32.whl", hash = "sha256:35a5b1c192496b6c25956eebfa963468935612206fd2543ac3ce981e6a5e0f03", size = 4404616, upload-time = "2026-07-23T15:19:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/f0/8e/ce9a23590cac33a6c24e6386cc0ffc55821cc13212acc822e98f00a67161/grpcio-1.83.0-cp310-cp310-win_amd64.whl", hash = "sha256:8f6c395e493d20c39b29392ca200e9aaeb78d0bc2f04db0c0a7da7ddc939aa57", size = 5162304, upload-time = "2026-07-23T15:19:11.467Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f6/3b781cd07a715ea5f5125ae264226e7fc4d87603d6d3955022cabfdc5da2/grpcio-1.83.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:8ff0b8767ddd62704e0d9571c1890af08d84a3a689ebba1807e62519d0b3277f", size = 6338720, upload-time = "2026-07-23T15:19:13.177Z" }, + { url = "https://files.pythonhosted.org/packages/21/cc/d14833d15d5984e366f1b027fa78bd038c9b028c66880bffb0f5a4d25ee2/grpcio-1.83.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:4772402f43517b4824980be4b3b2274a81eec0004a70009473c31b340d43e223", size = 12178773, upload-time = "2026-07-23T15:19:15.401Z" }, + { url = "https://files.pythonhosted.org/packages/6b/98/8acbb416544e7871132d8e42a07ed70c802d70e6a16c6009e505a34d32a4/grpcio-1.83.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f4cee5fc86e84a0cf7ad1574b454c3320e087c07f55b7df5dc0ac6a873fb90c0", size = 6921203, upload-time = "2026-07-23T15:19:17.824Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/0fdbfaf4fc54e5c88f6bce4008a065092fe7fbc4460eb5617ae8b20fd505/grpcio-1.83.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f5e822a7e7d03282f6ad225e710493c48b9057a353358344a5f7c42b2b37618d", size = 7648508, upload-time = "2026-07-23T15:19:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ea/107b9dbb2ed3ad14dd774fd3dde7d29ff9938a6c198654becb2c3a0e9a6a/grpcio-1.83.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5f410d7c2903eabb34789dfd6342eef04af1ad459943936b7e09a9f5bd417b9", size = 7079466, upload-time = "2026-07-23T15:19:21.478Z" }, + { url = "https://files.pythonhosted.org/packages/3b/06/9fa9941089e6fae83b060b6ce61c1e81053e52decae43197245f45e07d36/grpcio-1.83.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee94a4016fdf8699fb1fd8a38652475ff677f1c72074cee44deeeb9a7e95e745", size = 7605583, upload-time = "2026-07-23T15:19:23.74Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/f10fb56062dc2771c630827a82d9ad0ecd05cad572ea3b08d49f6631680a/grpcio-1.83.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6444666317338e903093c7c756e6cc88eee59f798cb8dd41e87725bf54e1617", size = 8637810, upload-time = "2026-07-23T15:19:25.536Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/f84927258f6a1b6ea6dea661fdc6de859b35e560c96f3012d15ccd39f85e/grpcio-1.83.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa074041231f03959cb097dd5517b0677b8ea49215bae01d5710a7b69dd59969", size = 8008021, upload-time = "2026-07-23T15:19:27.863Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/cdf72161397ccd29d4ca2192f641524536c9cf54ad948c9dd0e0e01138fa/grpcio-1.83.0-cp311-cp311-win32.whl", hash = "sha256:cb056f6e171c42639a50460b2929c82241fda51f71cf3dcdd68090fe45095a45", size = 4404376, upload-time = "2026-07-23T15:19:30.137Z" }, + { url = "https://files.pythonhosted.org/packages/df/ed/e0ffeb4c848699c194dc9fb6a29ab29bcb2b6aac8c416bf18c51bfe8242c/grpcio-1.83.0-cp311-cp311-win_amd64.whl", hash = "sha256:7416952ca770477990257206276999056f8316d79196f2f25942393e58a20b49", size = 5164469, upload-time = "2026-07-23T15:19:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/15/2b/51e32514a4e9b715375c99721aadff0f24164cc2049b8269eda4de82a814/grpcio-1.83.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:28f6c35ac8fcf10e4594f138e468f194360089dde40d126a7033e863fc479930", size = 6303167, upload-time = "2026-07-23T15:19:33.78Z" }, + { url = "https://files.pythonhosted.org/packages/39/33/b5b50fc2c6fbe350e04814047bb2d409feec7b36ef8b170254c050e06bc0/grpcio-1.83.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:33898e6a28e4ae598f1577cb1c4fec2a15c033d0ec52b9b45a09610dd045b9da", size = 12160538, upload-time = "2026-07-23T15:19:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5f/734e72e7b9f79bcf0b2c270b8d3bca0e4ebb97a27a50d06240b145f6d41e/grpcio-1.83.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6fb8a1dd0c6f0f931e69e9d0dc6d1c406ed2a44fa963414eafba07b7fb685d16", size = 6869310, upload-time = "2026-07-23T15:19:38.607Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/a1735f215b2a5cd43c38b79eac072ad197e61be9829905b6b29550abd0db/grpcio-1.83.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2b5e75c34842cd9c1b95285ca395c6a569664b81e3ffa6b714125922942abaaf", size = 7613472, upload-time = "2026-07-23T15:19:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/b2/78/c9e81f806ac704b6b145cb01628db398985b1f8dfdc10e23b55fb0902b3d/grpcio-1.83.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeb339838db07600481ef869507279b75326c75eac6d10f7afa62a0da1d2bcdd", size = 7040616, upload-time = "2026-07-23T15:19:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ba/94cd5af859876049d340480acbb61a959096c84b567f215534faa78d0424/grpcio-1.83.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f47d62808b4c0a97b78bff88a6d4ca283a2a492b9a04a87d814af95ca3b9c19c", size = 7570491, upload-time = "2026-07-23T15:19:44.357Z" }, + { url = "https://files.pythonhosted.org/packages/3e/15/108d30d5a5c964312ae8b9cb0e8cc5b3c1cc68d8f757cca52b3565534d26/grpcio-1.83.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62003babc444a606dcd1f009cd16391ce23669ae4ad6ec267a873da7937a69f5", size = 8605036, upload-time = "2026-07-23T15:19:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/ea/23/3828ae13c3db8233d123ad612747665817b952d8a954f32390230b582336/grpcio-1.83.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1aa567f8c3f19850ffd5d2858c9a8ea7c80f0db6c01186b71eb31e923ec984f5", size = 7981587, upload-time = "2026-07-23T15:19:48.913Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/77af31228f55f55a2a5112bb0077ad0a1c4d23dbb0c2853a62475bbdcc14/grpcio-1.83.0-cp312-cp312-win32.whl", hash = "sha256:cb2906c61db4f9c64cc360054b5df70eeb81846228e9e56a4944bd415a63dadc", size = 4394004, upload-time = "2026-07-23T15:19:50.618Z" }, + { url = "https://files.pythonhosted.org/packages/c0/da/f706e39550e7a3732ce2b9c5926107a93d74a802775b19b642a6df27dc96/grpcio-1.83.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c699bbb20f143c8f2bff219de578aa2dc1f919399d67dc702b038b986ee62df", size = 5158525, upload-time = "2026-07-23T15:19:52.246Z" }, + { url = "https://files.pythonhosted.org/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0", size = 6304480, upload-time = "2026-07-23T15:19:53.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1", size = 12154419, upload-time = "2026-07-23T15:19:56.3Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867", size = 6873200, upload-time = "2026-07-23T15:19:58.701Z" }, + { url = "https://files.pythonhosted.org/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881", size = 7618811, upload-time = "2026-07-23T15:20:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f", size = 7042310, upload-time = "2026-07-23T15:20:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61", size = 7575412, upload-time = "2026-07-23T15:20:04.712Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45", size = 8604248, upload-time = "2026-07-23T15:20:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c", size = 7977458, upload-time = "2026-07-23T15:20:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf", size = 4393993, upload-time = "2026-07-23T15:20:11.017Z" }, + { url = "https://files.pythonhosted.org/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735", size = 5159650, upload-time = "2026-07-23T15:20:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa", size = 6305607, upload-time = "2026-07-23T15:20:15.353Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c", size = 12166617, upload-time = "2026-07-23T15:20:17.435Z" }, + { url = "https://files.pythonhosted.org/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b", size = 6880213, upload-time = "2026-07-23T15:20:19.98Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c", size = 7618335, upload-time = "2026-07-23T15:20:22.161Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df", size = 7043416, upload-time = "2026-07-23T15:20:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b", size = 7583253, upload-time = "2026-07-23T15:20:26.313Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404", size = 8605102, upload-time = "2026-07-23T15:20:28.437Z" }, + { url = "https://files.pythonhosted.org/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af", size = 7979826, upload-time = "2026-07-23T15:20:31.182Z" }, + { url = "https://files.pythonhosted.org/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33", size = 4471765, upload-time = "2026-07-23T15:20:33.195Z" }, + { url = "https://files.pythonhosted.org/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9", size = 5298932, upload-time = "2026-07-23T15:20:35.48Z" }, +] + +[[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 = "hf-xet" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef", size = 920527, upload-time = "2026-08-03T22:33:13.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/62/3c062f593bd92ef4e77a0ef39541e3d82a0a1d3947c8a777a02a13a27828/hf_xet-1.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:70cbb9c896901600128cb9b6f06e132954fbede1db30f31f7c6c63f84cb7c31d", size = 4074584, upload-time = "2026-08-03T22:32:47.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1e/c0ad437dd267a8e435bef594acf781bbc3874ff0b6435b4962d03ecf7cc4/hf_xet-1.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:23379c2f9ec8696d952b16414a2bae72cad86a52df869b050698ba60f538c675", size = 3867381, upload-time = "2026-08-03T22:32:49.049Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ee/7c0d7b6ab336167531b1c30af2af003f054af4c749becbd7209ae33a77c3/hf_xet-1.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f7278c05c22fd60cb436cda1269649b3e81db65ecdc8496e5e164aa4143e7b", size = 4453982, upload-time = "2026-08-03T22:32:50.568Z" }, + { url = "https://files.pythonhosted.org/packages/63/06/ad8eab1c9525246650cbaa821caa3cdbaca734ab1a5b8c91bea09cbd8d69/hf_xet-1.6.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:948f15d3a9545cfe5932f6bd8b440f6ae630aee108f14b7bd6c561f7c2dcc522", size = 4249445, upload-time = "2026-08-03T22:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/d8/26/1eee8aedb0dafc1ab9717dc9ac602cde33361b232dc06803f1f6ed18b58c/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5153e6bb103ad49d6ea9f1b2e230db5a2ea32551ad09a706d2f61d7c7c80d80e", size = 4451099, upload-time = "2026-08-03T22:32:54.114Z" }, + { url = "https://files.pythonhosted.org/packages/67/57/0b88af1f194ab6c9c650547d9cc06bfeaab836ae4dcdb331676bfb8be95a/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:35cec30d75c6f9eb9c16a77cef68e85a103b72e24d4b473714ec9ff06428bab9", size = 4664712, upload-time = "2026-08-03T22:32:55.547Z" }, + { url = "https://files.pythonhosted.org/packages/53/a0/26b717a9d1840e8abf48dcec64b5ed8fbe472671d38ad28d30e147132b33/hf_xet-1.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5789835d7c6bc9436962853192082374297fb72d7eff7e7762ec25ceb7e25338", size = 4025906, upload-time = "2026-08-03T22:32:57.391Z" }, + { url = "https://files.pythonhosted.org/packages/49/f6/4a9966633c6fef83af997e2cff68ec1963676d412bdfd096df2a93b8e185/hf_xet-1.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:75765820ce4700db3750c94acc8fe27c5fae4c9ec000a0dbac3ca082acf97765", size = 3849221, upload-time = "2026-08-03T22:32:59.123Z" }, + { url = "https://files.pythonhosted.org/packages/a2/50/7afa2c9c787405864fc47a0d1bbc02c62e9101947ed43c1f43899fc7d91d/hf_xet-1.6.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d", size = 4071729, upload-time = "2026-08-03T22:33:00.721Z" }, + { url = "https://files.pythonhosted.org/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a", size = 3876287, upload-time = "2026-08-03T22:33:02.251Z" }, + { url = "https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f", size = 4464663, upload-time = "2026-08-03T22:33:03.802Z" }, + { url = "https://files.pythonhosted.org/packages/9a/69/1f0cbc2fb22ae6082d094f743d1b8945a3f36f6089cb95f42b7ee348cda7/hf_xet-1.6.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7", size = 4262538, upload-time = "2026-08-03T22:33:05.287Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3a/4f4f2301ade26e404462d3336fa11f7958d914cabbabdd6e03c3c5d5658c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb", size = 4460520, upload-time = "2026-08-03T22:33:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/311725e2a905534dfee2dcb5b08414f249147f1f12252bfc2bd24caa075c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c", size = 4675937, upload-time = "2026-08-03T22:33:08.616Z" }, + { url = "https://files.pythonhosted.org/packages/98/b7/8c59a66d15205024662f1d66968136f13893f96df1ddc5087e2e281fc95f/hf_xet-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b", size = 4033128, upload-time = "2026-08-03T22:33:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359, upload-time = "2026-08-03T22:33:11.725Z" }, +] + +[[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 = "httpcore2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/b9/be66eb0decd730d89b9c94f930e4b8d87787b05724bb84af98bfd825f72c/httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826", size = 208805, upload-time = "2026-05-25T22:16:50.434Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f7/b4d41eaae2869d31356bc4bbf546f44fae83ff298af0a043ca0625b06773/httptools-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77", size = 113527, upload-time = "2026-05-25T22:16:51.672Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e4/77487e14fc7be47180fd0eb4267c7486d0cc59b74031839a3daf8650136b/httptools-0.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4", size = 450035, upload-time = "2026-05-25T22:16:53.313Z" }, + { url = "https://files.pythonhosted.org/packages/da/72/5a8f787e323f56fbd86c32a4be92a86776e4cfe8b4317db999f452028362/httptools-0.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb", size = 451101, upload-time = "2026-05-25T22:16:54.696Z" }, + { url = "https://files.pythonhosted.org/packages/ed/41/b44a25560955197674b6744cb903664300e239235a5eaa69df0890d87054/httptools-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813", size = 436140, upload-time = "2026-05-25T22:16:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/74/b0/054aac84c03d7e097bf4c605fb7e74eec3d65c0276adf64ee97f3a103ff5/httptools-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba", size = 437041, upload-time = "2026-05-25T22:16:57.716Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e8/86b85bbc0ac7892232f1a99ab96a9aa71936984fa06adfc0afc83ca7789e/httptools-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557", size = 90454, upload-time = "2026-05-25T22:16:58.871Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[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 = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "httpx2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/ae/222a91937ebee7f62c0ca8f5ee0afd97577caf24c0abb927d1f5c7e9f6d2/huggingface_hub-1.28.0.tar.gz", hash = "sha256:46a2e950c09234de54093d587d1675382f0d08dbd600d9fb599b5932f5b2c6cb", size = 959609, upload-time = "2026-08-18T12:27:15.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/0e/eafef18f1a75e125e68395db21131db0cf868a128ecd2fce69b4df6c584b/huggingface_hub-1.28.0-py3-none-any.whl", hash = "sha256:58a8bacb03072edfc38067065e9dc24bbb34805410fcd36a1632de0b329660bb", size = 793202, upload-time = "2026-08-18T12:27:12.719Z" }, +] + +[[package]] +name = "humanfriendly" +version = "10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "importlib-resources" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/06/b56dfa750b44e86157093bc8fca0ab81dccbf5260510de4eaf1cb69b5b99/importlib_resources-7.1.0.tar.gz", hash = "sha256:0722d4c6212489c530f2a145a34c0a7a3b4721bc96a15fada5930e2a0b760708", size = 44985, upload-time = "2026-04-12T16:36:09.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, +] + +[[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 = "instructor" +version = "1.15.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "docstring-parser" }, + { name = "jinja2" }, + { name = "jiter" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "pydantic-core" }, + { name = "requests" }, + { name = "rich" }, + { name = "tenacity" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/24/f6b28e83b3194c6223ed7c6eed5724687f6ecd378ec2ff24044f0cbf1f09/instructor-1.15.4.tar.gz", hash = "sha256:ea2280c3678d0f6891c4d826104f95624b680e69877113a6345b1d7c9027ba0f", size = 70049678, upload-time = "2026-06-28T07:36:43.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/8d/f668a30fff4d25b36533355e23aeb0b5724df4628eb974124ed64b7bcf8d/instructor-1.15.4-py3-none-any.whl", hash = "sha256:00e0ecda80fd9746fb6d082d3f9641e193adb1d8849f0775f91519a82aeff968", size = 252522, upload-time = "2026-06-28T07:36:36.863Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "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/64/2e/a9959997739c403378d0a4a3a1c4ed80b60aeace216c4d37b303a9fc60a4/jiter-0.14.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:02f36a5c700f105ac04a6556fe664a59037a2c200db3b7e88784fac2ddf02531", size = 316927, upload-time = "2026-04-10T14:25:40.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/72/b6de8a531e0adbadd839bec301165feb1fccf00e9ff55073ba2dd20f0043/jiter-0.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41eab6c09ceffb6f0fe25e214b3068146edb1eda3649ca2aee2a061029c7ba2e", size = 321181, upload-time = "2026-04-10T14:25:42.621Z" }, + { url = "https://files.pythonhosted.org/packages/db/d8/2040b9efa13c917f855c40890ae4119fe02c25b7c7677d5b4fa820a851fc/jiter-0.14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cf4d4c109641f9cfaf4a7b6aebd51654e405cd00fa9ebbf87163b8b97b325aa", size = 347387, upload-time = "2026-04-10T14:25:44.212Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/655c0ad5ce6a8e90f9068c175b8a236877d753e460762b3183c136db1c5b/jiter-0.14.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b80c7b41a628e6be2213ad0ece763c5f88aa5ee003fa394d58acaaee1f4b8342", size = 373083, upload-time = "2026-04-10T14:25:45.55Z" }, + { url = "https://files.pythonhosted.org/packages/f1/66/549c40fa068f08710b7570869c306a051eb67a29758bd64f4114f730554c/jiter-0.14.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb3dbf7cc0d4dbe73cce307ebe7eefa7f73a7d3d854dd119ea0c243f03e40927", size = 463639, upload-time = "2026-04-10T14:25:47.452Z" }, + { url = "https://files.pythonhosted.org/packages/25/2f/97a32a05fed14ed58a18e181fdfb619e05163f3726b54ee6080ec0539c09/jiter-0.14.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7054adcdeb06b46efd17b5734f75817a44a2d06d3748e36c3a023a1bb52af9ec", size = 380735, upload-time = "2026-04-10T14:25:49.305Z" }, + { url = "https://files.pythonhosted.org/packages/2a/3b/4347e1d6c2a973d653bbb7a2d671a2d2426e54b52ba735b8ff0d0a29b75c/jiter-0.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d597cd1bf6790376f3fffc7c708766e57301d99a19314824ea0ccc9c3c70e1e2", size = 358632, upload-time = "2026-04-10T14:25:50.931Z" }, + { url = "https://files.pythonhosted.org/packages/ef/24/ca452fbf2ea33548ed30ce68a39a50442d3f7c9bf0704a7af958a930c057/jiter-0.14.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:df63a14878da754427926281626fd3ee249424a186e25a274e78176d42945264", size = 359969, upload-time = "2026-04-10T14:25:52.381Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a3/94470a0d199287caabeb4da2bb2ae5f6d17f3cf05dfc975d7cb064d58e0f/jiter-0.14.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ea73187627bcc5810e085df715e8a99da8bdfd96a7eb36b4b4df700ba6d4c9c", size = 397529, upload-time = "2026-04-10T14:25:53.801Z" }, + { url = "https://files.pythonhosted.org/packages/cf/71/6768edc09d7c45c39f093feb3de105fa718a3e982b5208b8a2ed6382b44b/jiter-0.14.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9f541eaf7bb8382367a1a23d6fc3d6aad57f8dd8c18c3c17f838bee20f217220", size = 522342, upload-time = "2026-04-10T14:25:55.396Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6b/5c2e17559a0f4e96e934479f7137df46c939e983fa05244e674815befb73/jiter-0.14.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:107465250de4fce00fdb47166bcd51df8e634e049541174fe3c71848e44f52ce", size = 556784, upload-time = "2026-04-10T14:25:56.927Z" }, + { url = "https://files.pythonhosted.org/packages/b1/83/c25f3556a60fc74d11199100f1b6cc0c006b815c8494dea8ca16fe398732/jiter-0.14.0-cp310-cp310-win32.whl", hash = "sha256:ffb2a08a406465bb076b7cc1df41d833106d3cf7905076cc73f0cb90078c7d10", size = 208439, upload-time = "2026-04-10T14:25:58.796Z" }, + { url = "https://files.pythonhosted.org/packages/2e/99/781a1b413f0989b7f2ea203b094b331685f1a35e52e0a45e5d000ecaab27/jiter-0.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb8b682d10cb0cce7ff4c1af7244af7022c9b01ae16d46c357bdd0df13afb25d", size = 204558, upload-time = "2026-04-10T14:26:00.208Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/198ae537fccb7080a0ed655eb56abf64a92f79489dfbf79f40fa34225bcd/jiter-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7e791e247b8044512e070bd1f3633dc08350d32776d2d6e7473309d0edf256a2", size = 316896, upload-time = "2026-04-10T14:26:01.986Z" }, + { url = "https://files.pythonhosted.org/packages/cf/34/da67cff3fce964a36d03c3e365fb0f8726ade2a6cfd4d3c70107e216ead6/jiter-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71527ce13fd5a0c4e40ad37331f8c547177dbb2dd0a93e5278b6a5eecf748804", size = 321085, upload-time = "2026-04-10T14:26:03.364Z" }, + { url = "https://files.pythonhosted.org/packages/ed/36/4c72e67180d4e71a4f5dcf7886d0840e83c49ab11788172177a77570326e/jiter-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02c4a7ab56f746014874f2c525584c0daca1dec37f66fd707ecef3b7e5c2228c", size = 347393, upload-time = "2026-04-10T14:26:05.314Z" }, + { url = "https://files.pythonhosted.org/packages/bc/db/9b39e09ceafa9878235c0fc29e3e3f9b12a4c6a98ea3085b998cadf3accc/jiter-0.14.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:376e9dafff914253bb9d46cdc5f7965607fbe7feb0a491c34e35f92b2770702e", size = 372937, upload-time = "2026-04-10T14:26:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/b0/96/0dcba1d7a82c1b720774b48ef239376addbaf30df24c34742ac4a57b67b2/jiter-0.14.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23ad2a7a9da1935575c820428dd8d2490ce4d23189691ce33da1fc0a58e14e1c", size = 463646, upload-time = "2026-04-10T14:26:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e3/f61b71543e746e6b8b805e7755814fc242715c16f1dba58e1cbccb8032c2/jiter-0.14.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b3ddf5786bc7732d293bba3411ac637ecfa200a39983166d1df86a59a43c9f", size = 380225, upload-time = "2026-04-10T14:26:10.161Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5e/0ddeb7096aca099114abe36c4921016e8d251e6f35f5890240b31f1f60ae/jiter-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c001d5a646c2a50dc055dd526dad5d5245969e8234d2b1131d0451e81f3a373", size = 358682, upload-time = "2026-04-10T14:26:11.574Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d1/fe0c46cd7fda9cad8f1ff9ad217dc61f1e4280b21052ec6dfe88c1446ef2/jiter-0.14.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:834bb5bdabca2e91592a03d373838a8d0a1b8bbde7077ae6913fd2fc51812d00", size = 359973, upload-time = "2026-04-10T14:26:13.316Z" }, + { url = "https://files.pythonhosted.org/packages/ac/21/f5317f91729b501019184771c80d60abd89907009e7bfa6c7e348c5bdd44/jiter-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4e9178be60e229b1b2b0710f61b9e24d1f4f8556985a83ff4c4f95920eea7314", size = 397568, upload-time = "2026-04-10T14:26:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/79d8f33fb2bf168db0df5c9cd16fe440a8ada57e929d3677b22712c2568f/jiter-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7e4ccff04ec03614e62c613e976a3a5860dc9714ce8266f44328bdc8b1cab2c", size = 522535, upload-time = "2026-04-10T14:26:16.956Z" }, + { url = "https://files.pythonhosted.org/packages/5c/00/d1e3ff3d2a465e67f08507d74bafb2dcd29eba91dc939820e39e8dea38b8/jiter-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:69539d936fb5d55caf6ecd33e2e884de083ff0ea28579780d56c4403094bb8d9", size = 556709, upload-time = "2026-04-10T14:26:18.5Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/bbb2189f62ace8d95e869aa4c84c9946616f301e2d02895a6f20dcc3bba3/jiter-0.14.0-cp311-cp311-win32.whl", hash = "sha256:4927d09b3e572787cc5e0a5318601448e1ab9391bcef95677f5840c2d00eaa6d", size = 208660, upload-time = "2026-04-10T14:26:20.511Z" }, + { url = "https://files.pythonhosted.org/packages/b8/86/c500b53dcbf08575f5963e536ebd757a1f7c568272ba5d180b212c9a87fb/jiter-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:42d6ed359ac49eb922fdd565f209c57340aa06d589c84c8413e42a0f9ae1b842", size = 204659, upload-time = "2026-04-10T14:26:22.152Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/a676249049d42cb29bef82233e4fe0524d414cbe3606c7a4b311193c2f77/jiter-0.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:6dd689f5f4a5a33747b28686e051095beb214fe28cfda5e9fe58a295a788f593", size = 194772, upload-time = "2026-04-10T14:26:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295, upload-time = "2026-04-10T14:26:24.887Z" }, + { url = "https://files.pythonhosted.org/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898, upload-time = "2026-04-10T14:26:26.601Z" }, + { url = "https://files.pythonhosted.org/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730, upload-time = "2026-04-10T14:26:28.326Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102, upload-time = "2026-04-10T14:26:30.089Z" }, + { url = "https://files.pythonhosted.org/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335, upload-time = "2026-04-10T14:26:31.911Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536, upload-time = "2026-04-10T14:26:33.595Z" }, + { url = "https://files.pythonhosted.org/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859, upload-time = "2026-04-10T14:26:35.098Z" }, + { url = "https://files.pythonhosted.org/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626, upload-time = "2026-04-10T14:26:36.685Z" }, + { url = "https://files.pythonhosted.org/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172, upload-time = "2026-04-10T14:26:38.097Z" }, + { url = "https://files.pythonhosted.org/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300, upload-time = "2026-04-10T14:26:39.532Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059, upload-time = "2026-04-10T14:26:40.991Z" }, + { url = "https://files.pythonhosted.org/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030, upload-time = "2026-04-10T14:26:42.517Z" }, + { url = "https://files.pythonhosted.org/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603, upload-time = "2026-04-10T14:26:44.328Z" }, + { url = "https://files.pythonhosted.org/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525, upload-time = "2026-04-10T14:26:46Z" }, + { url = "https://files.pythonhosted.org/packages/97/2a/09f70020898507a89279659a1afe3364d57fc1b2c89949081975d135f6f5/jiter-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94", size = 315502, upload-time = "2026-04-10T14:26:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/d6/be/080c96a45cd74f9fce5db4fd68510b88087fb37ffe2541ff73c12db92535/jiter-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a", size = 314870, upload-time = "2026-04-10T14:26:49.149Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5e/2d0fee155826a968a832cc32438de5e2a193292c8721ca70d0b53e58245b/jiter-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1", size = 343406, upload-time = "2026-04-10T14:26:50.762Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/bf9ee0d3a4f8dc0d679fc1337f874fe60cdbf841ebbb304b374e1c9aaceb/jiter-0.14.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62fe2451f8fcc0240261e6a4df18ecbcd58327857e61e625b2393ea3b468aac9", size = 369415, upload-time = "2026-04-10T14:26:52.188Z" }, + { url = "https://files.pythonhosted.org/packages/0f/83/8e8561eadba31f4d3948a5b712fb0447ec71c3560b57a855449e7b8ddc98/jiter-0.14.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6112f26f5afc75bcb475787d29da3aa92f9d09c7858f632f4be6ffe607be82e9", size = 461456, upload-time = "2026-04-10T14:26:53.611Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c9/c5299e826a5fe6108d172b344033f61c69b1bb979dd8d9ddd4278a160971/jiter-0.14.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:215a6cb8fb7dc702aa35d475cc00ddc7f970e5c0b1417fb4b4ac5d82fa2a29db", size = 378488, upload-time = "2026-04-10T14:26:55.211Z" }, + { url = "https://files.pythonhosted.org/packages/5d/37/c16d9d15c0a471b8644b1abe3c82668092a707d9bedcf076f24ff2e380cd/jiter-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ab96a30fb3cb2c7e0cd33f7616c8860da5f5674438988a54ac717caccdbaa", size = 353242, upload-time = "2026-04-10T14:26:56.705Z" }, + { url = "https://files.pythonhosted.org/packages/58/ea/8050cb0dc654e728e1bfacbc0c640772f2181af5dedd13ae70145743a439/jiter-0.14.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3a99c1387b1f2928f799a9de899193484d66206a50e98233b6b088a7f0c1edb2", size = 356823, upload-time = "2026-04-10T14:26:58.281Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/cf71506d270e5f84d97326bf220e47aed9b95e9a4a060758fb07772170ab/jiter-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab18d11074485438695f8d34a1b6da61db9754248f96d51341956607a8f39985", size = 392564, upload-time = "2026-04-10T14:27:00.018Z" }, + { url = "https://files.pythonhosted.org/packages/b0/cc/8c6c74a3efb5bd671bfd14f51e8a73375464ca914b1551bc3b40e26ac2c9/jiter-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:801028dcfc26ac0895e4964cbc0fd62c73be9fd4a7d7b1aaf6e5790033a719b7", size = 520322, upload-time = "2026-04-10T14:27:01.664Z" }, + { url = "https://files.pythonhosted.org/packages/41/24/68d7b883ec959884ddf00d019b2e0e82ba81b167e1253684fa90519ce33c/jiter-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ad425b087aafb4a1c7e1e98a279200743b9aaf30c3e0ba723aec93f061bd9bc8", size = 552619, upload-time = "2026-04-10T14:27:03.316Z" }, + { url = "https://files.pythonhosted.org/packages/b6/89/b1a0985223bbf3150ff9e8f46f98fc9360c1de94f48abe271bbe1b465682/jiter-0.14.0-cp313-cp313-win32.whl", hash = "sha256:882bcb9b334318e233950b8be366fe5f92c86b66a7e449e76975dfd6d776a01f", size = 205699, upload-time = "2026-04-10T14:27:04.662Z" }, + { url = "https://files.pythonhosted.org/packages/4c/19/3f339a5a7f14a11730e67f6be34f9d5105751d547b615ef593fa122a5ded/jiter-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:9b8c571a5dba09b98bd3462b5a53f27209a5cbbe85670391692ede71974e979f", size = 201323, upload-time = "2026-04-10T14:27:06.139Z" }, + { url = "https://files.pythonhosted.org/packages/50/56/752dd89c84be0e022a8ea3720bcfa0a8431db79a962578544812ce061739/jiter-0.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:34f19dcc35cb1abe7c369b3756babf8c7f04595c0807a848df8f26ef8298ef92", size = 191099, upload-time = "2026-04-10T14:27:07.564Z" }, + { url = "https://files.pythonhosted.org/packages/91/28/292916f354f25a1fe8cf2c918d1415c699a4a659ae00be0430e1c5d9ffea/jiter-0.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e89bcd7d426a75bb4952c696b267075790d854a07aad4c9894551a82c5b574ab", size = 320880, upload-time = "2026-04-10T14:27:09.326Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c7/b002a7d8b8957ac3d469bd59c18ef4b1595a5216ae0de639a287b9816023/jiter-0.14.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b25beaa0d4447ea8c7ae0c18c688905d34840d7d0b937f2f7bdd52162c98a40", size = 346563, upload-time = "2026-04-10T14:27:11.287Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3b/f8d07580d8706021d255a6356b8fab13ee4c869412995550ce6ed4ddf97d/jiter-0.14.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:651a8758dd413c51e3b7f6557cdc6921faf70b14106f45f969f091f5cda990ea", size = 357928, upload-time = "2026-04-10T14:27:12.729Z" }, + { url = "https://files.pythonhosted.org/packages/47/5b/ac1a974da29e35507230383110ffec59998b290a8732585d04e19a9eb5ba/jiter-0.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e1a7eead856a5038a8d291f1447176ab0b525c77a279a058121b5fccee257f6f", size = 203519, upload-time = "2026-04-10T14:27:14.125Z" }, + { url = "https://files.pythonhosted.org/packages/96/6d/9fc8433d667d2454271378a79747d8c76c10b51b482b454e6190e511f244/jiter-0.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e692633a12cda97e352fdcd1c4acc971b1c28707e1e33aeef782b0cbf051975", size = 190113, upload-time = "2026-04-10T14:27:16.638Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/32/a1/ef34ca2cab2962598591636a1804b93645821201cc0095d4a93a9a329c9d/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a25ffa2dbbdf8721855612f6dca15c108224b12d0c4024d0ac3d7902132b4211", size = 311366, upload-time = "2026-04-10T14:28:27.943Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/520576a532a6b8a6f42747afed289c8448c879a34d7802fe2c832d4fd38f/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ac9cbaa86c10996b92bd12c91659b60f939f8e28fcfa6bc11a0e90a774ce95b", size = 309873, upload-time = "2026-04-10T14:28:29.688Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7c/c16db114ea1f2f532f198aa8dc39585026af45af362c69a0492f31bc4821/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:844e73b6c56b505e9e169234ea3bdea2ea43f769f847f47ac559ba1d2361ebea", size = 344816, upload-time = "2026-04-10T14:28:31.348Z" }, + { url = "https://files.pythonhosted.org/packages/99/8f/15e7741ff19e9bcd4d753f7ff22f988fd54592f134ca13701c13ea8c20e0/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e52c076f187405fc21523c746c04399c9af8ece566077ed147b2126f2bcba577", size = 351445, upload-time = "2026-04-10T14:28:33.093Z" }, + { url = "https://files.pythonhosted.org/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810, upload-time = "2026-04-10T14:28:34.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443, upload-time = "2026-04-10T14:28:36.658Z" }, + { url = "https://files.pythonhosted.org/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039, upload-time = "2026-04-10T14:28:38.356Z" }, + { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "json-repair" +version = "0.60.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a6/d69888cb4ffde30e80db1e6c32caaadd2f984a80067d5ea72c2cb3f61c3f/json_repair-0.60.1.tar.gz", hash = "sha256:841661cdd2df507c9a4e189097f38ca6bc372e06d4b4e36d72e590f68176c290", size = 49451, upload-time = "2026-06-03T17:28:44.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/1f/2a2b5eea8ef5762a86ad3f8fddddaaba2c0d76dd44e644b9158900868bec/json_repair-0.60.1-py3-none-any.whl", hash = "sha256:ba6ff974f2a8bef2f7768144a7f03f870a816443f03da27a49cdd0ec31a78049", size = 48045, upload-time = "2026-06-03T17:28:43.038Z" }, +] + +[[package]] +name = "json5" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/3d/bbe62f3d0c05a689c711cff57b2e3ac3d3e526380adb7c781989f075115c/json5-0.10.0.tar.gz", hash = "sha256:e66941c8f0a02026943c52c2eb34ebeb2a6f819a0be05920a6f5243cd30fd559", size = 48202, upload-time = "2024-11-26T19:56:37.823Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/42/797895b952b682c3dafe23b1834507ee7f02f4d6299b65aaa61425763278/json5-0.10.0-py3-none-any.whl", hash = "sha256:19b23410220a7271e8377f81ba8aacba2fdd56947fbb137ee5977cbe1f5e8dfa", size = 34049, upload-time = "2024-11-26T19:56:36.649Z" }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "kubernetes" +version = "36.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "certifi" }, + { name = "durationpy" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "six" }, + { name = "urllib3" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/57/b07b96353f902aa1bdbe00e878e3a12a137977d03a962479785576aa8ec9/kubernetes-36.0.3.tar.gz", hash = "sha256:36993ed25ce59b789c9341473a228fcf268504a2fec7c2b2b1531d73072e5ce7", size = 2337528, upload-time = "2026-07-13T20:38:12.128Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/30/a96d47df739689ac0001ade0afefc16e3b477fc2fb426b568515fdc8afce/kubernetes-36.0.3-py2.py3-none-any.whl", hash = "sha256:8fde9241c4b298e6374a069dcf728359b4e72c2fb29489a975ba4e1c047cf10f", size = 4618066, upload-time = "2026-07-13T20:38:10.172Z" }, +] + +[[package]] +name = "lance-namespace" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lance-namespace-urllib3-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/93/da5f7fcac690db9b282a3439ed9e34960c147619a0d6e1f4eb8cd240e7a5/lance_namespace-0.11.1.tar.gz", hash = "sha256:f67cfbbe0647b7cb42f23b673e7edf8a75b7d8a047265a916492f8d247ee1bc2", size = 11631, upload-time = "2026-08-18T17:40:06.294Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/bc/601f2b3cc4cfa0070d858a33223bc823fffdd7981a25c45984a5216ca952/lance_namespace-0.11.1-py3-none-any.whl", hash = "sha256:07643fce9a42ad4d58cc8bf91e3f592bc7f4cbd8d0ad5233223506debf67551c", size = 13507, upload-time = "2026-08-18T17:40:03.561Z" }, +] + +[[package]] +name = "lance-namespace-urllib3-client" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/c5/2bdd0ff98b469894c8a73be809d26ffdad5402517b0e5f9e758026cba29e/lance_namespace_urllib3_client-0.11.1.tar.gz", hash = "sha256:145a9e9424d7597487249b5b95ee274423bf2910e1a9160b6a07b676b61ea46a", size = 237345, upload-time = "2026-08-18T17:40:07.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/eaaefd55d1190291207049fedc6b3eb22b506e57d6de91bae46bbaaa9c60/lance_namespace_urllib3_client-0.11.1-py3-none-any.whl", hash = "sha256:36537f529294da6d884ba0fe783704483f0a75463497c7705fd083a4d0257990", size = 406311, upload-time = "2026-08-18T17:40:04.842Z" }, +] + +[[package]] +name = "lancedb" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "lance-namespace" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "overrides", marker = "python_full_version < '3.12'" }, + { name = "packaging" }, + { name = "pyarrow" }, + { name = "pydantic" }, + { name = "tqdm" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2f/1577778ad57dba0c55dc13d87230583e14541c82562483ecf8bb2f8e8a00/lancedb-0.30.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:be2a9a43a65c330ccfd08115afb26106cd8d16788522fe7693d3a1f4e01ad321", size = 41959907, upload-time = "2026-03-16T23:03:04.551Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ca/8c2a04ce499a2a97d1a0de2b7e84fa8166f988a9a495e1ada860110489c2/lancedb-0.30.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be6a4ba2a1799a426cbf2ba5ea2559a7389a569e9a31f2409d531ceb59d42f35", size = 43873070, upload-time = "2026-03-16T23:11:01.352Z" }, + { url = "https://files.pythonhosted.org/packages/16/68/e01bf7837454a5ce9e2f6773905e07b09a949bc88136c0773c8166ed7729/lancedb-0.30.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a967ec05f9930770aeb077bc5579769b1bedf559fcd03a592d9644084625918", size = 46891197, upload-time = "2026-03-16T23:14:39.18Z" }, + { url = "https://files.pythonhosted.org/packages/43/d1/9085ad17abd98f3a180d7860df3190b2d76f99f533c76d7c7494cec4139d/lancedb-0.30.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:05c66f40f7d4f6f24208e786c40f84b87b1b8e55505305849dd3fed3b78431a3", size = 43877660, upload-time = "2026-03-16T23:11:00.837Z" }, + { url = "https://files.pythonhosted.org/packages/ea/69/504ee25c57c3f23c80276b5b7b5e4c0f98a5197a7e9e51d3c50500d2b53a/lancedb-0.30.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:bdcd27d98554ed11b6f345b14d1307b0e2332d5654767e9ee2e23d9b2d6513d1", size = 46932144, upload-time = "2026-03-16T23:15:00.474Z" }, + { url = "https://files.pythonhosted.org/packages/2c/85/d5550f22023e672af1945394f7a06a578fcab2980ecc6666acef3428a771/lancedb-0.30.0-cp39-abi3-win_amd64.whl", hash = "sha256:4751ff0446b90be4d4dccfe05f6c105f403a05f3b8531ab99eedc1c656aca950", size = 51121310, upload-time = "2026-03-16T23:43:23.89Z" }, +] + +[[package]] +name = "langchain-core" +version = "1.5.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "jsonpatch" }, + { name = "langchain-protocol" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/b9/893806b89f77e1271fe6e10ce41682ff5fe43d071564e1d8e39dbb5d4d6d/langchain_core-1.5.6.tar.gz", hash = "sha256:b5f73bd9688c457b31ec73657a0ad56948f889fae27acee79286e9c285632ee6", size = 984873, upload-time = "2026-08-17T21:26:35.921Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/0a/890504397885c9d1ae45f2e06c3000dd9f4445439602b6a990a88b32c0ac/langchain_core-1.5.6-py3-none-any.whl", hash = "sha256:d6cf37bf695ecc22cddeb8461a684e353190b2ce430d99eb22bc11c0c7c00ea5", size = 567016, upload-time = "2026-08-17T21:26:34.595Z" }, +] + +[[package]] +name = "langchain-protocol" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, +] + +[[package]] +name = "langgraph" +version = "1.2.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-prebuilt" }, + { name = "langgraph-sdk" }, + { name = "pydantic" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/0d/c8e7ee98896659e1b6555db0ab115a9ca899844744645d5d894032bab1d7/langgraph-1.2.11.tar.gz", hash = "sha256:9ecfe11e50d338b34b15cf4d8a442642de103e8ae6971320efba84e4542eb363", size = 725753, upload-time = "2026-08-11T14:00:36.945Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7f/c5c30e4be99ff821029c7ac872a480676bb179c9f3df85ea3f38d13f86d4/langgraph-1.2.11-py3-none-any.whl", hash = "sha256:8bab70de7b2d00b5300fb289bcf38d8b241400f3184c1e95e8ce706fb0e8686b", size = 248854, upload-time = "2026-08-11T14:00:35.494Z" }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "ormsgpack" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/e1/089c4c9e0a2fec7f883f82ae8e6a727138d50074cfeb6644bc2d13b1019b/langgraph_checkpoint-4.2.0.tar.gz", hash = "sha256:51a593b6bee684b0818e5d6e58e28ab340c6db7794575056ce7bd1b746a84ed7", size = 180239, upload-time = "2026-08-07T20:05:03.756Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/71/3b475f09bd57d3a5649792c66353312b4432afd843f301739dfcebd157f0/langgraph_checkpoint-4.2.0-py3-none-any.whl", hash = "sha256:0547fd228935a0b758865de3a3d6d7a2537c308895d0f9ab092ce9151b5da942", size = 56833, upload-time = "2026-08-07T20:05:02.655Z" }, +] + +[[package]] +name = "langgraph-prebuilt" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/66/ed9b93f56bc17ef22d551892f0ac2b225a97fe0fcf23a511b857f70d590b/langgraph_prebuilt-1.1.0.tar.gz", hash = "sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528", size = 178833, upload-time = "2026-05-12T03:37:49.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/43/3fe1a700b8490ed02679cdbbc8c915eb23a092faf496c9c1118abcd10be3/langgraph_prebuilt-1.1.0-py3-none-any.whl", hash = "sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9", size = 41043, upload-time = "2026-05-12T03:37:48.007Z" }, +] + +[[package]] +name = "langgraph-sdk" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "langchain-core" }, + { name = "langchain-protocol" }, + { name = "orjson" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" }, +] + +[[package]] +name = "langsmith" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, + { name = "websockets" }, + { name = "xxhash" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/62/a917e87073767db8ca335c3f5a42fb4c5336509b8d5b03e165e46da47e70/langsmith-0.11.0.tar.gz", hash = "sha256:7339f90e6fd9a1a009445b5084a7a0e56a8b6f17305ee5d7e8c5e7582217854f", size = 4805612, upload-time = "2026-08-14T12:56:55.409Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/aa/18d334f04c12bb9154568747539a1143a93d0c34b894662c065454f396c8/langsmith-0.11.0-py3-none-any.whl", hash = "sha256:e87a3929915936c066b3fa3283ec3f3f0013e2ef7f98a443a7fbe3fab8e784a3", size = 737950, upload-time = "2026-08-14T12:56:53.014Z" }, +] + +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + +[[package]] +name = "llama-index-core" +version = "0.14.23" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aiosqlite" }, + { name = "banks" }, + { name = "dataclasses-json" }, + { name = "deprecated" }, + { name = "dirtyjson" }, + { name = "filetype" }, + { name = "fsspec" }, + { name = "httpx" }, + { name = "llama-index-workflows" }, + { name = "nest-asyncio" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "nltk" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pillow" }, + { name = "platformdirs" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "tenacity" }, + { name = "tiktoken" }, + { name = "tinytag" }, + { name = "tqdm" }, + { name = "typing-extensions" }, + { name = "typing-inspect" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/ac/f885ae14317af43a026c909ea4d2083fcee2f0d014f90426b5b9aa1f9912/llama_index_core-0.14.23.tar.gz", hash = "sha256:c4baf2f2ab4f84e95090fe7941e0c87d6c514304f7bd2a749b8fa22164c1822b", size = 11588373, upload-time = "2026-06-24T19:35:55.43Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/d5/05d61f34c01c6578fb758d0a3ddef58d36c6ffa9a9f84a5c9a16262ad94d/llama_index_core-0.14.23-py3-none-any.whl", hash = "sha256:6a54d267826732a8507f81df40785b107f7592af20f451a39a59005147caf84c", size = 11924908, upload-time = "2026-06-24T19:35:52.833Z" }, +] + +[[package]] +name = "llama-index-instrumentation" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/d0/671b23ccff255c9bce132a84ffd5a6f4541ceefdeab9c1786b08c9722f2e/llama_index_instrumentation-0.5.0.tar.gz", hash = "sha256:eeb724648b25d149de882a5ac9e21c5acb1ce780da214bda2b075341af29ad8e", size = 43831, upload-time = "2026-03-12T20:17:06.742Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/45/6dcaccef44e541ffa138e4b45e33e0d40ab2a7d845338483954fcf77bc75/llama_index_instrumentation-0.5.0-py3-none-any.whl", hash = "sha256:aaab83cddd9dd434278891012d8995f47a3bc7ed1736a371db90965348c56a21", size = 16444, upload-time = "2026-03-12T20:17:05.957Z" }, +] + +[[package]] +name = "llama-index-workflows" +version = "2.23.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llama-index-instrumentation" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/2f/97339da1feaa09716287c8d30ee04b0e6dfa1ab2e2aa4ac2249e2011762d/llama_index_workflows-2.23.2.tar.gz", hash = "sha256:6d791acbe11947c7e9cb02c886356e29c2b4d66f97c6593fe7eb3d288e85b351", size = 137112, upload-time = "2026-08-17T16:38:16.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/05/bacf8b5ae50d035c3e27ea7cf83761f62705c490e6bbf37e60b8dd53a06e/llama_index_workflows-2.23.2-py3-none-any.whl", hash = "sha256:7dba6033fe776a25dcc74c163543a12a93d5f524cc7df33b193ea383b3577b6d", size = 165028, upload-time = "2026-08-17T16:38:15.005Z" }, +] + +[[package]] +name = "logfire-api" +version = "4.40.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/5f/f4d0fb5c29d876c533daf415c0d961e1c4d0284167ed0834644a28581230/logfire_api-4.40.0.tar.gz", hash = "sha256:f4631d5ca6af95e9d4dadc4f63619ebb8f2300eecfca0ca99c84403d6ea605de", size = 90781, upload-time = "2026-08-05T11:27:00.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/be/ebe35d94e7d567b58d79bd7e1085fe85195ad4b8c8df8882a5a46caa4984/logfire_api-4.40.0-py3-none-any.whl", hash = "sha256:f8b7309235a942368b927f00e0a1869ff0820833f264a30e77a35f1da829c130", size = 140593, upload-time = "2026-08-05T11:26:58.395Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "marshmallow" +version = "3.26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, +] + +[[package]] +name = "mcp" +version = "1.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[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 = "mmh3" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/1a/edb23803a168f070ded7a3014c6d706f63b90c84ccc024f89d794a3b7a6d/mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad", size = 33775, upload-time = "2026-03-05T15:55:57.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/bb/88ee54afa5644b0f35ab5b435f208394feb963e5bb47c4e404deb625ffa4/mmh3-5.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5d87a3584093e1a89987e3d36d82c98d9621b2cb944e22a420aa1401e096758f", size = 56080, upload-time = "2026-03-05T15:53:40.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/bf/5404c2fd6ac84819e8ff1b7e34437b37cf55a2b11318894909e7bb88de3f/mmh3-5.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30e4d2084df019880d55f6f7bea35328d9b464ebee090baa372c096dc77556fb", size = 40462, upload-time = "2026-03-05T15:53:41.751Z" }, + { url = "https://files.pythonhosted.org/packages/de/0b/52bffad0b52ae4ea53e222b594bd38c08ecac1fc410323220a7202e43da5/mmh3-5.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bbc17250b10d3466875a40a52520a6bac3c02334ca709207648abd3c223ed5c", size = 40077, upload-time = "2026-03-05T15:53:42.753Z" }, + { url = "https://files.pythonhosted.org/packages/a0/9e/326c93d425b9fa4cbcdc71bc32aaba520db37577d632a24d25d927594eca/mmh3-5.2.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:76219cd1eefb9bf4af7856e3ae563d15158efa145c0aab01e9933051a1954045", size = 95302, upload-time = "2026-03-05T15:53:43.867Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b1/e20d5f0d19c4c0f3df213fa7dcfa0942c4fb127d38e11f398ae8ddf6cccc/mmh3-5.2.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb9d44c25244e11c8be3f12c938ca8ba8404620ef8092245d2093c6ab3df260f", size = 101174, upload-time = "2026-03-05T15:53:45.194Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4a/1a9bb3e33c18b1e1cee2c249a3053c4d4d9c93ecb30738f39a62249a7e86/mmh3-5.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d5d542bf2abd0fd0361e8017d03f7cb5786214ceb4a40eef1539d6585d93386", size = 103979, upload-time = "2026-03-05T15:53:46.334Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/dab9ee7545429e7acdd38d23d0104471d31de09a0c695f1b751e0ff34532/mmh3-5.2.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:08043f7cb1fb9467c3fbbbaea7896986e7fbc81f4d3fd9289a73d9110ab6207a", size = 110898, upload-time = "2026-03-05T15:53:47.443Z" }, + { url = "https://files.pythonhosted.org/packages/72/08/408f11af7fe9e76b883142bb06536007cc7f237be2a5e9ad4e837716e627/mmh3-5.2.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:add7ac388d1e0bf57259afbcf9ed05621a3bf11ce5ee337e7536f1e1aaf056b0", size = 118308, upload-time = "2026-03-05T15:53:49.1Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/0551be7fe0000736d9ad12ffa1f130d7a0c17b49193d6dc41c82bd9404c6/mmh3-5.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41105377f6282e8297f182e393a79cfffd521dde37ace52b106373bdcd9ca5cb", size = 101671, upload-time = "2026-03-05T15:53:50.317Z" }, + { url = "https://files.pythonhosted.org/packages/44/17/6e4f80c4e6ad590139fa2017c3aeca54e7cc9ef68e08aa142a0c90f40a97/mmh3-5.2.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3cb61db880ec11e984348227b333259994c2c85caa775eb7875decb3768db890", size = 96682, upload-time = "2026-03-05T15:53:51.48Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a7/b82fccd38c1fa815de72e94ebe9874562964a10e21e6c1bc3b01d3f15a0e/mmh3-5.2.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b5378de2b139c3a830f0209c1e91f7705919a4b3e563a10955104f5097a70a", size = 110287, upload-time = "2026-03-05T15:53:52.68Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a1/2644069031c8cec0be46f0346f568a53f42fddd843f03cc890306699c1e2/mmh3-5.2.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e904f2417f0d6f6d514f3f8b836416c360f306ddaee1f84de8eef1e722d212e5", size = 111899, upload-time = "2026-03-05T15:53:53.791Z" }, + { url = "https://files.pythonhosted.org/packages/51/7b/6614f3eb8fb33f931fa7616c6d477247e48ec6c5082b02eeeee998cffa94/mmh3-5.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1fbb0a99125b1287c6d9747f937dc66621426836d1a2d50d05aecfc81911b57", size = 100078, upload-time = "2026-03-05T15:53:55.234Z" }, + { url = "https://files.pythonhosted.org/packages/27/9a/dd4d5a5fb893e64f71b42b69ecae97dd78db35075412488b24036bc5599c/mmh3-5.2.1-cp310-cp310-win32.whl", hash = "sha256:b4cce60d0223074803c9dbe0721ad3fa51dafe7d462fee4b656a1aa01ee07518", size = 40756, upload-time = "2026-03-05T15:53:56.319Z" }, + { url = "https://files.pythonhosted.org/packages/c9/34/0b25889450f8aeffcec840aa73251e853f059c1b72ed1d1c027b956f95f5/mmh3-5.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:6f01f044112d43a20be2f13a11683666d87151542ad627fe41a18b9791d2802f", size = 41519, upload-time = "2026-03-05T15:53:57.41Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/8fd42e3c526d0bcb1db7f569c0de6729e180860a0495e387a53af33c2043/mmh3-5.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:7501e9be34cb21e72fcfe672aafd0eee65c16ba2afa9dcb5500a587d3a0580f0", size = 39285, upload-time = "2026-03-05T15:53:58.697Z" }, + { url = "https://files.pythonhosted.org/packages/65/d7/3312a59df3c1cdd783f4cf0c4ee8e9decff9c5466937182e4cc7dbbfe6c5/mmh3-5.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dae0f0bd7d30c0ad61b9a504e8e272cb8391eed3f1587edf933f4f6b33437450", size = 56082, upload-time = "2026-03-05T15:53:59.702Z" }, + { url = "https://files.pythonhosted.org/packages/61/96/6f617baa098ca0d2989bfec6d28b5719532cd8d8848782662f5b755f657f/mmh3-5.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9aeaf53eaa075dd63e81512522fd180097312fb2c9f476333309184285c49ce0", size = 40458, upload-time = "2026-03-05T15:54:01.548Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b4/9cd284bd6062d711e13d26c04d4778ab3f690c1c38a4563e3c767ec8802e/mmh3-5.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0634581290e6714c068f4aa24020acf7880927d1f0084fa753d9799ae9610082", size = 40079, upload-time = "2026-03-05T15:54:02.743Z" }, + { url = "https://files.pythonhosted.org/packages/f6/09/a806334ce1d3d50bf782b95fcee8b3648e1e170327d4bb7b4bad2ad7d956/mmh3-5.2.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080c0637aea036f35507e803a4778f119a9b436617694ae1c5c366805f1e997", size = 97242, upload-time = "2026-03-05T15:54:04.536Z" }, + { url = "https://files.pythonhosted.org/packages/ee/93/723e317dd9e041c4dc4566a2eb53b01ad94de31750e0b834f1643905e97c/mmh3-5.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db0562c5f71d18596dcd45e854cf2eeba27d7543e1a3acdafb7eef728f7fe85d", size = 103082, upload-time = "2026-03-05T15:54:06.387Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/f96121e69cc48696075071531cf574f112e1ffd08059f4bffb41210e6fc5/mmh3-5.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d9f9a3ce559a5267014b04b82956993270f63ec91765e13e9fd73daf2d2738e", size = 106054, upload-time = "2026-03-05T15:54:07.506Z" }, + { url = "https://files.pythonhosted.org/packages/82/49/192b987ec48d0b2aecf8ac285a9b11fbc00030f6b9c694664ae923458dde/mmh3-5.2.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:960b1b3efa39872ac8b6cc3a556edd6fb90ed74f08c9c45e028f1005b26aa55d", size = 112910, upload-time = "2026-03-05T15:54:09.403Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a1/03e91fd334ed0144b83343a76eb11f17434cd08f746401488cfeafb2d241/mmh3-5.2.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d30b650595fdbe32366b94cb14f30bb2b625e512bd4e1df00611f99dc5c27fd4", size = 120551, upload-time = "2026-03-05T15:54:10.587Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/b89a71d2ff35c3a764d1c066c7313fc62c7cc48fa48a4b3b0304a4a0146f/mmh3-5.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82f3802bfc4751f420d591c5c864de538b71cea117fce67e4595c2afede08a15", size = 99096, upload-time = "2026-03-05T15:54:11.76Z" }, + { url = "https://files.pythonhosted.org/packages/36/b5/613772c1c6ed5f7b63df55eb131e887cc43720fec392777b95a79d34e640/mmh3-5.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:915e7a2418f10bd1151b1953df06d896db9783c9cfdb9a8ee1f9b3a4331ab503", size = 98524, upload-time = "2026-03-05T15:54:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/1524566fe8eaf871e4f7bc44095929fcd2620488f402822d848df19d679c/mmh3-5.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fc78739b5ec6e4fb02301984a3d442a91406e7700efbe305071e7fd1c78278f2", size = 106239, upload-time = "2026-03-05T15:54:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/04/94/21adfa7d90a7a697137ad6de33eeff6445420ca55e433a5d4919c79bc3b5/mmh3-5.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:41aac7002a749f08727cb91babff1daf8deac317c0b1f317adc69be0e6c375d1", size = 109797, upload-time = "2026-03-05T15:54:15.819Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e6/1aacc3a219e1aa62fa65669995d4a3562b35be5200ec03680c7e4bec9676/mmh3-5.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9d8089d853c7963a8ce87fff93e2a67075c0bc08684a08ea6ad13577c38ffc38", size = 97228, upload-time = "2026-03-05T15:54:16.992Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b9/5e4cca8dcccf298add0a27f3c357bc8cf8baf821d35cdc6165e4bd5a48b0/mmh3-5.2.1-cp311-cp311-win32.whl", hash = "sha256:baeb47635cb33375dee4924cd93d7f5dcaa786c740b08423b0209b824a1ee728", size = 40751, upload-time = "2026-03-05T15:54:18.714Z" }, + { url = "https://files.pythonhosted.org/packages/72/fc/5b11d49247f499bcda591171e9cf3b6ee422b19e70aa2cef2e0ae65ca3b9/mmh3-5.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:1e4ecee40ba19e6975e1120829796770325841c2f153c0e9aecca927194c6a2a", size = 41517, upload-time = "2026-03-05T15:54:19.764Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/2a511ee8a1c2a527c77726d5231685b72312c5a1a1b7639ad66a9652aa84/mmh3-5.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:c302245fd6c33d96bd169c7ccf2513c20f4c1e417c07ce9dce107c8bc3f8411f", size = 39287, upload-time = "2026-03-05T15:54:20.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/94/bc5c3b573b40a328c4d141c20e399039ada95e5e2a661df3425c5165fd84/mmh3-5.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0cc21533878e5586b80d74c281d7f8da7932bc8ace50b8d5f6dbf7e3935f63f1", size = 56087, upload-time = "2026-03-05T15:54:21.92Z" }, + { url = "https://files.pythonhosted.org/packages/f6/80/64a02cc3e95c3af0aaa2590849d9ed24a9f14bb93537addde688e039b7c3/mmh3-5.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4eda76074cfca2787c8cf1bec603eaebdddd8b061ad5502f85cddae998d54f00", size = 40500, upload-time = "2026-03-05T15:54:22.953Z" }, + { url = "https://files.pythonhosted.org/packages/8b/72/e6d6602ce18adf4ddcd0e48f2e13590cc92a536199e52109f46f259d3c46/mmh3-5.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eee884572b06bbe8a2b54f424dbd996139442cf83c76478e1ec162512e0dd2c7", size = 40034, upload-time = "2026-03-05T15:54:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/59/c2/bf4537a8e58e21886ef16477041238cab5095c836496e19fafc34b7445d2/mmh3-5.2.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0b7e803191db5f714d264044e06189c8ccd3219e936cc184f07106bd17fd7b", size = 97292, upload-time = "2026-03-05T15:54:25.335Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e2/51ed62063b44d10b06d975ac87af287729eeb5e3ed9772f7584a17983e90/mmh3-5.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e6c219e375f6341d0959af814296372d265a8ca1af63825f65e2e87c618f006", size = 103274, upload-time = "2026-03-05T15:54:26.44Z" }, + { url = "https://files.pythonhosted.org/packages/75/ce/12a7524dca59eec92e5b31fdb13ede1e98eda277cf2b786cf73bfbc24e81/mmh3-5.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26fb5b9c3946bf7f1daed7b37e0c03898a6f062149127570f8ede346390a0825", size = 106158, upload-time = "2026-03-05T15:54:28.578Z" }, + { url = "https://files.pythonhosted.org/packages/86/1f/d3ba6dd322d01ab5d44c46c8f0c38ab6bbbf9b5e20e666dfc05bf4a23604/mmh3-5.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c38d142c706201db5b2345166eeef1e7740e3e2422b470b8ba5c8727a9b4c7a", size = 113005, upload-time = "2026-03-05T15:54:29.767Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a9/15d6b6f913294ea41b44d901741298e3718e1cb89ee626b3694625826a43/mmh3-5.2.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50885073e2909251d4718634a191c49ae5f527e5e1736d738e365c3e8be8f22b", size = 120744, upload-time = "2026-03-05T15:54:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/70b73923fd0284c439860ff5c871b20210dfdbe9a6b9dd0ee6496d77f174/mmh3-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3f99e1756fc48ad507b95e5d86f2fb21b3d495012ff13e6592ebac14033f166", size = 99111, upload-time = "2026-03-05T15:54:32.353Z" }, + { url = "https://files.pythonhosted.org/packages/dd/38/99f7f75cd27d10d8b899a1caafb9d531f3903e4d54d572220e3d8ac35e89/mmh3-5.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62815d2c67f2dd1be76a253d88af4e1da19aeaa1820146dec52cf8bee2958b16", size = 98623, upload-time = "2026-03-05T15:54:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/fd/68/6e292c0853e204c44d2f03ea5f090be3317a0e2d9417ecb62c9eb27687df/mmh3-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8f767ba0911602ddef289404e33835a61168314ebd3c729833db2ed685824211", size = 106437, upload-time = "2026-03-05T15:54:35.177Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c6/fedd7284c459cfb58721d461fcf5607a4c1f5d9ab195d113d51d10164d16/mmh3-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:67e41a497bac88cc1de96eeba56eeb933c39d54bc227352f8455aa87c4ca4000", size = 110002, upload-time = "2026-03-05T15:54:36.673Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ac/ca8e0c19a34f5b71390171d2ff0b9f7f187550d66801a731bb68925126a4/mmh3-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d74a03fb57757ece25aa4b3c1c60157a1cece37a020542785f942e2f827eed5", size = 97507, upload-time = "2026-03-05T15:54:37.804Z" }, + { url = "https://files.pythonhosted.org/packages/df/94/6ebb9094cfc7ac5e7950776b9d13a66bb4a34f83814f32ba2abc9494fc68/mmh3-5.2.1-cp312-cp312-win32.whl", hash = "sha256:7374d6e3ef72afe49697ecd683f3da12f4fc06af2d75433d0580c6746d2fa025", size = 40773, upload-time = "2026-03-05T15:54:40.077Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/cd3527198cf159495966551c84a5f36805a10ac17b294f41f67b83f6a4d6/mmh3-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9fed49c6ce4ed7e73f13182760c65c816da006debe67f37635580dfb0fae00", size = 41560, upload-time = "2026-03-05T15:54:41.148Z" }, + { url = "https://files.pythonhosted.org/packages/15/96/6fe5ebd0f970a076e3ed5512871ce7569447b962e96c125528a2f9724470/mmh3-5.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfcb95d9a744e6e2827dfc66ad10e1020e0cac255eb7f85652832d5a264c2fc", size = 39313, upload-time = "2026-03-05T15:54:42.171Z" }, + { url = "https://files.pythonhosted.org/packages/25/a5/9daa0508a1569a54130f6198d5462a92deda870043624aa3ea72721aa765/mmh3-5.2.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e", size = 40832, upload-time = "2026-03-05T15:54:43.212Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6b/3230c6d80c1f4b766dedf280a92c2241e99f87c1504ff74205ec8cebe451/mmh3-5.2.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d", size = 41964, upload-time = "2026-03-05T15:54:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/62/fb/648bfddb74a872004b6ee751551bfdda783fe6d70d2e9723bad84dbe5311/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6", size = 39114, upload-time = "2026-03-05T15:54:45.205Z" }, + { url = "https://files.pythonhosted.org/packages/95/c2/ab7901f87af438468b496728d11264cb397b3574d41506e71b92128e0373/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a482ac121de6973897c92c2f31defc6bafb11c83825109275cffce54bb64933f", size = 39819, upload-time = "2026-03-05T15:54:46.509Z" }, + { url = "https://files.pythonhosted.org/packages/2f/ed/6f88dda0df67de1612f2e130ffea34cf84aaee5bff5b0aff4dbff2babe34/mmh3-5.2.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:17fbb47f0885ace8327ce1235d0416dc86a211dcd8cc1e703f41523be32cfec8", size = 40330, upload-time = "2026-03-05T15:54:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/3d/66/7516d23f53cdf90f43fce24ab80c28f45e6851d78b46bef8c02084edf583/mmh3-5.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d51fde50a77f81330523562e3c2734ffdca9c4c9e9d355478117905e1cfe16c6", size = 56078, upload-time = "2026-03-05T15:54:48.9Z" }, + { url = "https://files.pythonhosted.org/packages/bc/34/4d152fdf4a91a132cb226b671f11c6b796eada9ab78080fb5ce1e95adaab/mmh3-5.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19bbd3b841174ae6ed588536ab5e1b1fe83d046e668602c20266547298d939a9", size = 40498, upload-time = "2026-03-05T15:54:49.942Z" }, + { url = "https://files.pythonhosted.org/packages/d4/4c/8e3af1b6d85a299767ec97bd923f12b06267089c1472c27c1696870d1175/mmh3-5.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be77c402d5e882b6fbacfd90823f13da8e0a69658405a39a569c6b58fdb17b03", size = 40033, upload-time = "2026-03-05T15:54:50.994Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/966ea560e32578d453c9e9db53d602cbb1d0da27317e232afa7c38ceba11/mmh3-5.2.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fd96476f04db5ceba1cfa0f21228f67c1f7402296f0e73fee3513aa680ad237b", size = 97320, upload-time = "2026-03-05T15:54:52.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/0d/2c5f9893b38aeb6b034d1a44ecd55a010148054f6a516abe53b5e4057297/mmh3-5.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:707151644085dd0f20fe4f4b573d28e5130c4aaa5f587e95b60989c5926653b5", size = 103299, upload-time = "2026-03-05T15:54:53.569Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fc/2ebaef4a4d4376f89761274dc274035ffd96006ab496b4ee5af9b08f21a9/mmh3-5.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3737303ca9ea0f7cb83028781148fcda4f1dac7821db0c47672971dabcf63593", size = 106222, upload-time = "2026-03-05T15:54:55.092Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/ea7ffe126d0ba0406622602a2d05e1e1a6841cc92fc322eb576c95b27fad/mmh3-5.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2778fed822d7db23ac5008b181441af0c869455b2e7d001f4019636ac31b6fe4", size = 113048, upload-time = "2026-03-05T15:54:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/85/57/9447032edf93a64aa9bef4d9aa596400b1756f40411890f77a284f6293ca/mmh3-5.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d57dea657357230cc780e13920d7fa7db059d58fe721c80020f94476da4ca0a1", size = 120742, upload-time = "2026-03-05T15:54:57.453Z" }, + { url = "https://files.pythonhosted.org/packages/53/82/a86cc87cc88c92e9e1a598fee509f0409435b57879a6129bf3b3e40513c7/mmh3-5.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:169e0d178cb59314456ab30772429a802b25d13227088085b0d49b9fe1533104", size = 99132, upload-time = "2026-03-05T15:54:58.583Z" }, + { url = "https://files.pythonhosted.org/packages/54/f7/6b16eb1b40ee89bb740698735574536bc20d6cdafc65ae702ea235578e05/mmh3-5.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e4e1f580033335c6f76d1e0d6b56baf009d1a64d6a4816347e4271ba951f46d", size = 98686, upload-time = "2026-03-05T15:55:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/e8/88/a601e9f32ad1410f438a6d0544298ea621f989bd34a0731a7190f7dec799/mmh3-5.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2bd9f19f7f1fcebd74e830f4af0f28adad4975d40d80620be19ffb2b2af56c9f", size = 106479, upload-time = "2026-03-05T15:55:01.532Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/ce29ae3dfc4feec4007a437a1b7435fb9507532a25147602cd5b52be86db/mmh3-5.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c88653877aeb514c089d1b3d473451677b8b9a6d1497dbddf1ae7934518b06d2", size = 110030, upload-time = "2026-03-05T15:55:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/13/30/ae444ef2ff87c805d525da4fa63d27cda4fe8a48e77003a036b8461cfd5c/mmh3-5.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fceef7fe67c81e1585198215e42ad3fdba3a25644beda8fbdaf85f4d7b93175a", size = 97536, upload-time = "2026-03-05T15:55:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f9/dc3787ee5c813cc27fe79f45ad4500d9b5437f23a7402435cc34e07c7718/mmh3-5.2.1-cp313-cp313-win32.whl", hash = "sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b", size = 40769, upload-time = "2026-03-05T15:55:05.277Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/850e0b5a1e97799822ebfc4ca0e8c6ece3ed8baf7dcdf64de817dfdda2ca/mmh3-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229", size = 41563, upload-time = "2026-03-05T15:55:06.283Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/98c90b28e1da5458e19fbfaf4adb5289208d3bfccd45dd14eab216a2f0bb/mmh3-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d", size = 39310, upload-time = "2026-03-05T15:55:07.323Z" }, + { url = "https://files.pythonhosted.org/packages/63/b4/65bc1fb2bb7f83e91c30865023b1847cf89a5f237165575e8c83aa536584/mmh3-5.2.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d771f085fcdf4035786adfb1d8db026df1eb4b41dac1c3d070d1e49512843227", size = 40794, upload-time = "2026-03-05T15:55:09.773Z" }, + { url = "https://files.pythonhosted.org/packages/c4/86/7168b3d83be8eb553897b1fac9da8bbb06568e5cfe555ffc329ebb46f59d/mmh3-5.2.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:7f196cd7910d71e9d9860da0ff7a77f64d22c1ad931f1dd18559a06e03109fc0", size = 41923, upload-time = "2026-03-05T15:55:10.924Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9b/b653ab611c9060ce8ff0ba25c0226757755725e789292f3ca138a58082cd/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b1f12bd684887a0a5d55e6363ca87056f361e45451105012d329b86ec19dbe0b", size = 39131, upload-time = "2026-03-05T15:55:11.961Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5a2e0d34ab4d33543f01121e832395ea510132ea8e52cdf63926d9d81754/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d106493a60dcb4aef35a0fac85105e150a11cf8bc2b0d388f5a33272d756c966", size = 39825, upload-time = "2026-03-05T15:55:13.013Z" }, + { url = "https://files.pythonhosted.org/packages/bd/69/81699a8f39a3f8d368bec6443435c0c392df0d200ad915bf0d222b588e03/mmh3-5.2.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:44983e45310ee5b9f73397350251cdf6e63a466406a105f1d16cb5baa659270b", size = 40344, upload-time = "2026-03-05T15:55:14.026Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b3/71c8c775807606e8fd8acc5c69016e1caf3200d50b50b6dd4b40ce10b76c/mmh3-5.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:368625fb01666655985391dbad3860dc0ba7c0d6b9125819f3121ee7292b4ac8", size = 56291, upload-time = "2026-03-05T15:55:15.137Z" }, + { url = "https://files.pythonhosted.org/packages/6f/75/2c24517d4b2ce9e4917362d24f274d3d541346af764430249ddcc4cb3a08/mmh3-5.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:72d1cc63bcc91e14933f77d51b3df899d6a07d184ec515ea7f56bff659e124d7", size = 40575, upload-time = "2026-03-05T15:55:16.518Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b9/e4a360164365ac9f07a25f0f7928e3a66eb9ecc989384060747aa170e6aa/mmh3-5.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e8b4b5580280b9265af3e0409974fb79c64cf7523632d03fbf11df18f8b0181e", size = 40052, upload-time = "2026-03-05T15:55:17.735Z" }, + { url = "https://files.pythonhosted.org/packages/97/ca/120d92223a7546131bbbc31c9174168ee7a73b1366f5463ffe69d9e691fe/mmh3-5.2.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4cbbde66f1183db040daede83dd86c06d663c5bb2af6de1142b7c8c37923dd74", size = 97311, upload-time = "2026-03-05T15:55:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/b6/71/c1a60c1652b8813ef9de6d289784847355417ee0f2980bca002fe87f4ae5/mmh3-5.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ff038d52ef6aa0f309feeba00c5095c9118d0abf787e8e8454d6048db2037fc", size = 103279, upload-time = "2026-03-05T15:55:20.448Z" }, + { url = "https://files.pythonhosted.org/packages/48/29/ad97f4be1509cdcb28ae32c15593ce7c415db47ace37f8fad35b493faa9a/mmh3-5.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4130d0b9ce5fad6af07421b1aecc7e079519f70d6c05729ab871794eded8617", size = 106290, upload-time = "2026-03-05T15:55:21.6Z" }, + { url = "https://files.pythonhosted.org/packages/77/29/1f86d22e281bd8827ba373600a4a8b0c0eae5ca6aa55b9a8c26d2a34decc/mmh3-5.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e0bfe77d238308839699944164b96a2eeccaf55f2af400f54dc20669d8d5f2", size = 113116, upload-time = "2026-03-05T15:55:22.826Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7c/339971ea7ed4c12d98f421f13db3ea576a9114082ccb59d2d1a0f00ccac1/mmh3-5.2.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f963eafc0a77a6c0562397da004f5876a9bcf7265a7bcc3205e29636bc4a1312", size = 120740, upload-time = "2026-03-05T15:55:24.3Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/3c7c4bdb8e926bb3c972d1e2907d77960c1c4b250b41e8366cf20c6e4373/mmh3-5.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:92883836caf50d5255be03d988d75bc93e3f86ba247b7ca137347c323f731deb", size = 99143, upload-time = "2026-03-05T15:55:25.456Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/33dd8706e732458c8375eae63c981292de07a406bad4ec03e5269654aa2c/mmh3-5.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57b52603e89355ff318025dd55158f6e71396c0f1f609d548e9ea9c94cc6ce0a", size = 98703, upload-time = "2026-03-05T15:55:26.723Z" }, + { url = "https://files.pythonhosted.org/packages/51/04/76bbce05df76cbc3d396f13b2ea5b1578ef02b6a5187e132c6c33f99d596/mmh3-5.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f40a95186a72fa0b67d15fef0f157bfcda00b4f59c8a07cbe5530d41ac35d105", size = 106484, upload-time = "2026-03-05T15:55:28.214Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8f/c6e204a2c70b719c1f62ffd9da27aef2dddcba875ea9c31ca0e87b975a46/mmh3-5.2.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:58370d05d033ee97224c81263af123dea3d931025030fd34b61227a768a8858a", size = 110012, upload-time = "2026-03-05T15:55:29.532Z" }, + { url = "https://files.pythonhosted.org/packages/e3/37/7181efd8e39db386c1ebc3e6b7d1f702a09d7c1197a6f2742ed6b5c16597/mmh3-5.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7be6dfb49e48fd0a7d91ff758a2b51336f1cd21f9d44b20f6801f072bd080cdd", size = 97508, upload-time = "2026-03-05T15:55:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/42/0f/afa7ca2615fd85e1469474bb860e381443d0b868c083b62b41cb1d7ca32f/mmh3-5.2.1-cp314-cp314-win32.whl", hash = "sha256:54fe8518abe06a4c3852754bfd498b30cc58e667f376c513eac89a244ce781a4", size = 41387, upload-time = "2026-03-05T15:55:32.403Z" }, + { url = "https://files.pythonhosted.org/packages/71/0d/46d42a260ee1357db3d486e6c7a692e303c017968e14865e00efa10d09fc/mmh3-5.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3f796b535008708846044c43302719c6956f39ca2d93f2edda5319e79a29efbb", size = 42101, upload-time = "2026-03-05T15:55:33.646Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7b/848a8378059d96501a41159fca90d6a99e89736b0afbe8e8edffeac8c74b/mmh3-5.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:cd471ede0d802dd936b6fab28188302b2d497f68436025857ca72cd3810423fe", size = 39836, upload-time = "2026-03-05T15:55:35.026Z" }, + { url = "https://files.pythonhosted.org/packages/27/61/1dabea76c011ba8547c25d30c91c0ec22544487a8750997a27a0c9e1180b/mmh3-5.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5174a697ce042fa77c407e05efe41e03aa56dae9ec67388055820fb48cf4c3ba", size = 57727, upload-time = "2026-03-05T15:55:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/731185950d1cf2d5e28979cc8593016ba1619a295faba10dda664a4931b5/mmh3-5.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a3984146e414684a6be2862d84fcb1035f4984851cb81b26d933bab6119bf00", size = 41308, upload-time = "2026-03-05T15:55:37.254Z" }, + { url = "https://files.pythonhosted.org/packages/76/aa/66c76801c24b8c9418b4edde9b5e57c75e72c94e29c48f707e3962534f18/mmh3-5.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bd6e7d363aa93bd3421b30b6af97064daf47bc96005bddba67c5ffbc6df426b8", size = 40758, upload-time = "2026-03-05T15:55:38.61Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bb/79a1f638a02f0ae389f706d13891e2fbf7d8c0a22ecde67ba828951bb60a/mmh3-5.2.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:113f78e7463a36dbbcea05bfe688efd7fa759d0f0c56e73c974d60dcfec3dfcc", size = 109670, upload-time = "2026-03-05T15:55:40.13Z" }, + { url = "https://files.pythonhosted.org/packages/26/94/8cd0e187a288985bcfc79bf5144d1d712df9dee74365f59d26e3a1865be6/mmh3-5.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e8ec5f606e0809426d2440e0683509fb605a8820a21ebd120dcdba61b74ef7f", size = 117399, upload-time = "2026-03-05T15:55:42.076Z" }, + { url = "https://files.pythonhosted.org/packages/42/94/dfea6059bd5c5beda565f58a4096e43f4858fb6d2862806b8bbd12cbb284/mmh3-5.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22b0f9971ec4e07e8223f2beebe96a6cfc779d940b6f27d26604040dd74d3a44", size = 120386, upload-time = "2026-03-05T15:55:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/47/cb/f9c45e62aaa67220179f487772461d891bb582bb2f9783c944832c60efd9/mmh3-5.2.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85ffc9920ffc39c5eee1e3ac9100c913a0973996fbad5111f939bbda49204bb7", size = 125924, upload-time = "2026-03-05T15:55:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/a5/83/fe54a4a7c11bc9f623dfc1707decd034245602b076dfc1dcc771a4163170/mmh3-5.2.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7aec798c2b01aaa65a55f1124f3405804184373abb318a3091325aece235f67c", size = 135280, upload-time = "2026-03-05T15:55:45.866Z" }, + { url = "https://files.pythonhosted.org/packages/97/67/fe7e9e9c143daddd210cd22aef89cbc425d58ecf238d2b7d9eb0da974105/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55dbbd8ffbc40d1697d5e2d0375b08599dae8746b0b08dea05eee4ce81648fac", size = 110050, upload-time = "2026-03-05T15:55:47.074Z" }, + { url = "https://files.pythonhosted.org/packages/43/c4/6d4b09fcbef80794de447c9378e39eefc047156b290fa3dd2d5257ca8227/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6c85c38a279ca9295a69b9b088a2e48aa49737bb1b34e6a9dc6297c110e8d912", size = 111158, upload-time = "2026-03-05T15:55:48.239Z" }, + { url = "https://files.pythonhosted.org/packages/81/a6/ca51c864bdb30524beb055a6d8826db3906af0834ec8c41d097a6e8573d5/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6290289fa5fb4c70fd7f72016e03633d60388185483ff3b162912c81205ae2cf", size = 116890, upload-time = "2026-03-05T15:55:49.405Z" }, + { url = "https://files.pythonhosted.org/packages/cc/04/5a1fe2e2ad843d03e89af25238cbc4f6840a8bb6c4329a98ab694c71deda/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4fc6cd65dc4d2fdb2625e288939a3566e36127a84811a4913f02f3d5931da52d", size = 123121, upload-time = "2026-03-05T15:55:50.61Z" }, + { url = "https://files.pythonhosted.org/packages/af/4d/3c820c6f4897afd25905270a9f2330a23f77a207ea7356f7aadace7273c0/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:623f938f6a039536cc02b7582a07a080f13fdfd48f87e63201d92d7e34d09a18", size = 110187, upload-time = "2026-03-05T15:55:52.143Z" }, + { url = "https://files.pythonhosted.org/packages/21/54/1d71cd143752361c0aebef16ad3f55926a6faf7b112d355745c1f8a25f7f/mmh3-5.2.1-cp314-cp314t-win32.whl", hash = "sha256:29bc3973676ae334412efdd367fcd11d036b7be3efc1ce2407ef8676dabfeb82", size = 41934, upload-time = "2026-03-05T15:55:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e4/63a2a88f31d93dea03947cccc2a076946857e799ea4f7acdecbf43b324aa/mmh3-5.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:28cfab66577000b9505a0d068c731aee7ca85cd26d4d63881fab17857e0fe1fb", size = 43036, upload-time = "2026-03-05T15:55:55.252Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0f/59204bf136d1201f8d7884cfbaf7498c5b4674e87a4c693f9bde63741ce1/mmh3-5.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dfd51b4c56b673dfbc43d7d27ef857dd91124801e2806c69bb45585ce0fa019b", size = 40391, upload-time = "2026-03-05T15:55:56.697Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, + { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, + { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, + { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "networkx" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.13.*'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "nltk" +version = "3.10.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "defusedxml" }, + { name = "joblib" }, + { name = "regex" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/e6/fe51d2bb1a3b446f59c5c8165999a9fee208bc346af90a7cbf7657bc0d75/nltk-3.10.3.tar.gz", hash = "sha256:bb9327a461c3811c2fa4900e03840401f2126adfb30c0072827c433bd2444ea4", size = 5137152, upload-time = "2026-08-12T23:46:37.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/6d/ebd2af4640b12168fdf0cb74b6118df2f32a2f62ec7e0c06fbfd80706639/nltk-3.10.3-py3-none-any.whl", hash = "sha256:ff9598a8e20518ee0d557745890cc4435b9578489e2dcbc69c4f81fa060caf7c", size = 1798643, upload-time = "2026-08-12T23:44:13.478Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.13.*'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/72/dccb0aaf40972777283303919f613964227266d0c13adebb79ac124f1c3e/numpy-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c", size = 16891693, upload-time = "2026-08-09T13:44:51.702Z" }, + { url = "https://files.pythonhosted.org/packages/60/2e/b5aee50a1f74ac815cf8331812cb8251e29024025de462e0c047641c614c/numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03", size = 11903109, upload-time = "2026-08-09T13:44:55.501Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb", size = 5350202, upload-time = "2026-08-09T13:44:58.401Z" }, + { url = "https://files.pythonhosted.org/packages/11/4b/dcd3b7eadaf4035d2c7a4289d232523a6964f602598ef7674e4bd7291f93/numpy-2.5.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414", size = 6687736, upload-time = "2026-08-09T13:45:00.813Z" }, + { url = "https://files.pythonhosted.org/packages/e5/21/4947e0e9d6c9fc2e2ff15b8949049ee44f63adb9cacc729ab8793f97e712/numpy-2.5.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9", size = 15612696, upload-time = "2026-08-09T13:45:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8", size = 16722264, upload-time = "2026-08-09T13:45:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/3f0be4c1b9fdf5dd5e708a6806978564d7c46a055c000496309ff2a2f8af/numpy-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab", size = 16974396, upload-time = "2026-08-09T13:45:11.316Z" }, + { url = "https://files.pythonhosted.org/packages/22/72/6262cbdeeb45da9d971e40715f579d791603ba8ec0b5e2db1ac55454421d/numpy-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7", size = 18476044, upload-time = "2026-08-09T13:45:14.869Z" }, + { url = "https://files.pythonhosted.org/packages/36/33/29208b8b075bde62d26a81d14b358c42b0f69b6cabd98d4ff97f37f22b05/numpy-2.5.2-cp312-cp312-win32.whl", hash = "sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a", size = 6072817, upload-time = "2026-08-09T13:45:17.867Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4", size = 12464674, upload-time = "2026-08-09T13:45:20.734Z" }, + { url = "https://files.pythonhosted.org/packages/14/52/032b97e00461ab0809bbe4c588b035620e5a14b8cdee47ecddefc7b17d33/numpy-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1", size = 10397131, upload-time = "2026-08-09T13:45:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" }, + { url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" }, + { url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" }, + { url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" }, + { url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" }, + { url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" }, + { url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" }, + { url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" }, + { url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" }, + { url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" }, + { url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" }, + { url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" }, + { url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" }, + { url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" }, + { url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" }, + { url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" }, + { url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" }, + { url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" }, + { url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.23.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coloredlogs" }, + { name = "flatbuffers" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/d6/311b1afea060015b56c742f3531168c1644650767f27ef40062569960587/onnxruntime-1.23.2-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:a7730122afe186a784660f6ec5807138bf9d792fa1df76556b27307ea9ebcbe3", size = 17195934, upload-time = "2025-10-27T23:06:14.143Z" }, + { url = "https://files.pythonhosted.org/packages/db/db/81bf3d7cecfbfed9092b6b4052e857a769d62ed90561b410014e0aae18db/onnxruntime-1.23.2-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:b28740f4ecef1738ea8f807461dd541b8287d5650b5be33bca7b474e3cbd1f36", size = 19153079, upload-time = "2025-10-27T23:05:57.686Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4d/a382452b17cf70a2313153c520ea4c96ab670c996cb3a95cc5d5ac7bfdac/onnxruntime-1.23.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f7d1fe034090a1e371b7f3ca9d3ccae2fabae8c1d8844fb7371d1ea38e8e8d2", size = 15219883, upload-time = "2025-10-22T03:46:21.66Z" }, + { url = "https://files.pythonhosted.org/packages/fb/56/179bf90679984c85b417664c26aae4f427cba7514bd2d65c43b181b7b08b/onnxruntime-1.23.2-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ca88747e708e5c67337b0f65eed4b7d0dd70d22ac332038c9fc4635760018f7", size = 17370357, upload-time = "2025-10-22T03:46:57.968Z" }, + { url = "https://files.pythonhosted.org/packages/cd/6d/738e50c47c2fd285b1e6c8083f15dac1a5f6199213378a5f14092497296d/onnxruntime-1.23.2-cp310-cp310-win_amd64.whl", hash = "sha256:0be6a37a45e6719db5120e9986fcd30ea205ac8103fd1fb74b6c33348327a0cc", size = 13467651, upload-time = "2025-10-27T23:06:11.904Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/467b00f09061572f022ffd17e49e49e5a7a789056bad95b54dfd3bee73ff/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:6f91d2c9b0965e86827a5ba01531d5b669770b01775b23199565d6c1f136616c", size = 17196113, upload-time = "2025-10-22T03:47:33.526Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/3c23a8f75f93122d2b3410bfb74d06d0f8da4ac663185f91866b03f7da1b/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:87d8b6eaf0fbeb6835a60a4265fde7a3b60157cf1b2764773ac47237b4d48612", size = 19153857, upload-time = "2025-10-22T03:46:37.578Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/506eed9af03d86f8db4880a4c47cd0dffee973ef7e4f4cff9f1d4bcf7d22/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbfd2fca76c855317568c1b36a885ddea2272c13cb0e395002c402f2360429a6", size = 15220095, upload-time = "2025-10-22T03:46:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/e9/80/113381ba832d5e777accedc6cb41d10f9eca82321ae31ebb6bcede530cea/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da44b99206e77734c5819aa2142c69e64f3b46edc3bd314f6a45a932defc0b3e", size = 17372080, upload-time = "2025-10-22T03:47:00.265Z" }, + { url = "https://files.pythonhosted.org/packages/3a/db/1b4a62e23183a0c3fe441782462c0ede9a2a65c6bbffb9582fab7c7a0d38/onnxruntime-1.23.2-cp311-cp311-win_amd64.whl", hash = "sha256:902c756d8b633ce0dedd889b7c08459433fbcf35e9c38d1c03ddc020f0648c6e", size = 13468349, upload-time = "2025-10-22T03:47:25.783Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9e/f748cd64161213adeef83d0cb16cb8ace1e62fa501033acdd9f9341fff57/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321", size = 17195929, upload-time = "2025-10-22T03:47:36.24Z" }, + { url = "https://files.pythonhosted.org/packages/91/9d/a81aafd899b900101988ead7fb14974c8a58695338ab6a0f3d6b0100f30b/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b", size = 19157705, upload-time = "2025-10-22T03:46:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/4e40f2fba272a6698d62be2cd21ddc3675edfc1a4b9ddefcc4648f115315/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c", size = 15226915, upload-time = "2025-10-22T03:46:27.773Z" }, + { url = "https://files.pythonhosted.org/packages/ef/88/9cc25d2bafe6bc0d4d3c1db3ade98196d5b355c0b273e6a5dc09c5d5d0d5/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77", size = 17382649, upload-time = "2025-10-22T03:47:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b4/569d298f9fc4d286c11c45e85d9ffa9e877af12ace98af8cab52396e8f46/onnxruntime-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435", size = 13470528, upload-time = "2025-10-22T03:47:28.106Z" }, + { url = "https://files.pythonhosted.org/packages/3d/41/fba0cabccecefe4a1b5fc8020c44febb334637f133acefc7ec492029dd2c/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:2ff531ad8496281b4297f32b83b01cdd719617e2351ffe0dba5684fb283afa1f", size = 17196337, upload-time = "2025-10-22T03:46:35.168Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f9/2d49ca491c6a986acce9f1d1d5fc2099108958cc1710c28e89a032c9cfe9/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:162f4ca894ec3de1a6fd53589e511e06ecdc3ff646849b62a9da7489dee9ce95", size = 19157691, upload-time = "2025-10-22T03:46:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a1/428ee29c6eaf09a6f6be56f836213f104618fb35ac6cc586ff0f477263eb/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45d127d6e1e9b99d1ebeae9bcd8f98617a812f53f46699eafeb976275744826b", size = 15226898, upload-time = "2025-10-22T03:46:30.039Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2b/b57c8a2466a3126dbe0a792f56ad7290949b02f47b86216cd47d857e4b77/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bace4e0d46480fbeeb7bbe1ffe1f080e6663a42d1086ff95c1551f2d39e7872", size = 17382518, upload-time = "2025-10-22T03:47:05.407Z" }, + { url = "https://files.pythonhosted.org/packages/4a/93/aba75358133b3a941d736816dd392f687e7eab77215a6e429879080b76b6/onnxruntime-1.23.2-cp313-cp313-win_amd64.whl", hash = "sha256:1f9cc0a55349c584f083c1c076e611a7c35d5b867d5d6e6d6c823bf821978088", size = 13470276, upload-time = "2025-10-22T03:47:31.193Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3d/6830fa61c69ca8e905f237001dbfc01689a4e4ab06147020a4518318881f/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d2385e774f46ac38f02b3a91a91e30263d41b2f1f4f26ae34805b2a9ddef466", size = 15229610, upload-time = "2025-10-22T03:46:32.239Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ca/862b1e7a639460f0ca25fd5b6135fb42cf9deea86d398a92e44dfda2279d/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145", size = 17394184, upload-time = "2025-10-22T03:47:08.127Z" }, +] + +[[package]] +name = "openai" +version = "2.54.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/50/9a/8c75e8c8a5b407a0586faeb2afac91674ff955c191ecc1d6d3b6669f6788/openai-2.54.0.tar.gz", hash = "sha256:e3e6f8bc1ba30ddf381ace1a14340eed381cb984a1a59bd0f34b5be3b5d49cfa", size = 1100285, upload-time = "2026-08-11T18:46:59.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/a8/bb76c7356de8ad57f59d5ff993d434df0607f07f08bcc9c9a5c275e399c0/openai-2.54.0-py3-none-any.whl", hash = "sha256:89089789197ccdb87f173a03145ed1598d00795220c93e96cf712b1cbf5e5f2b", size = 1660351, upload-time = "2026-08-11T18:46:56.684Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/9c/216acfeaedadf2e1937f4373929b20f73197c5c4a2546d4f584b7fa63813/opentelemetry_exporter_otlp_proto_common-1.42.1.tar.gz", hash = "sha256:04f1f01fb597c4249dfcd7f8b861c902c2102369d376d9d346ff38de4469a2ee", size = 21433, upload-time = "2026-05-21T16:32:55.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/43/2375e7612e1121a4518c17603b6e0b03ad94f565aafad53f464dc5be2bf6/opentelemetry_exporter_otlp_proto_common-1.42.1-py3-none-any.whl", hash = "sha256:f48d395ab815b444da118868977e9798ea354c25737d5cf39578ae894011c140", size = 17327, upload-time = "2026-05-21T16:32:33.387Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.42.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/87/87/ca7fc790dfdbcf4f9e9aab14a39ef1b7508ead13707e283de0b3131478d2/opentelemetry_exporter_otlp_proto_grpc-1.42.1.tar.gz", hash = "sha256:975c4461f167dd8ed8857d68d3b6b25f3d272eab896f6a9470d0f5b90e2faf15", size = 27140, upload-time = "2026-05-21T16:32:56.162Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/2b/28ba5b128f47fe8c3bab541000d6feb4b5a9bd26623ca013406f01c0fb60/opentelemetry_exporter_otlp_proto_grpc-1.42.1-py3-none-any.whl", hash = "sha256:0ae1177e2038b18a929b3098215243631ef91136cba26b7e2b12790ceb7e87cc", size = 19617, upload-time = "2026-05-21T16:32:34.278Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.42.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/77/32/826bfa1d80ecea24f47808de03cd4a0d13c17ecc07712f45123f0f61e4ac/opentelemetry_exporter_otlp_proto_http-1.42.1.tar.gz", hash = "sha256:bf142a21035d7571ac3a09cb2e5639f49886f243972883cfe777ed3bf02b734d", size = 25406, upload-time = "2026-05-21T16:32:56.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/96/82cb223a1502f0787d4bbff12907f5f8d870a50731febcd5818d93ef9555/opentelemetry_exporter_otlp_proto_http-1.42.1-py3-none-any.whl", hash = "sha256:00a16da1b312a1d6c7233d600d557c91df71125af73020f3b9a7765bd699d59d", size = 21793, upload-time = "2026-05-21T16:32:35.277Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/55/63eac3e1089b768ba014091fdd2ae8a9a440c821ef5e2b786909c94c8836/opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6", size = 45839, upload-time = "2026-05-21T16:33:03.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/9d/171c02c84a76940b7e601805b3bb536985aded9168fbcc9ba52f0a730fa2/opentelemetry_proto-1.42.1-py3-none-any.whl", hash = "sha256:dedb74cba2886c59c7789b227a7a670613025a07489040050aedff6e5c0fb43c", size = 71782, upload-time = "2026-05-21T16:32:44.867Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.42.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/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, +] + +[[package]] +name = "orjson" +version = "3.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/742fb1f62b825f2c010697eaf4e828004bc2a81e7e806666989c132c7c42/orjson-3.12.0.tar.gz", hash = "sha256:d14203fb1aae2ad9b3d52f8a0e82aeb10197ef1c9bc61da7f358bd70b00123d5", size = 4142915, upload-time = "2026-08-14T16:13:30.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/35/819eeb4fa8ee676d38fdbb8213a76fd496f7dbbfdfafa89d34e02b22dfac/orjson-3.12.0-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:747843254519dd43b93eee3153a19e5a509334320c4d2f823ec879232db5c796", size = 224133, upload-time = "2026-08-14T16:12:00.607Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/d9221d4a2b085b073fcddc91728d490f20b9cf010c62c2f42371ab997695/orjson-3.12.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:7c2ad193c8004254f34b499f3bd2c80f043d10754aff2b38f93da574f4883f98", size = 113669, upload-time = "2026-08-14T16:12:02.126Z" }, + { url = "https://files.pythonhosted.org/packages/15/12/644cbbcabb26df61d9ef0c66e6f2bf8b687cc7b66137597f2858951f1952/orjson-3.12.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:bc7a872f03522d90e0429e6c0c5cd23084f767bedcb4c58048eec19294613344", size = 130410, upload-time = "2026-08-14T16:12:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/14/6d/e3a8c34d687895aecd8b267a01c46106eb98d8424a83bfa7bacb723854f6/orjson-3.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18a87929f31d94a77f7dc93cf527e91f39ce7fe7813d588a4de2507efd32a387", size = 131101, upload-time = "2026-08-14T16:12:04.918Z" }, + { url = "https://files.pythonhosted.org/packages/75/20/930824c07685c22af23f26818ed3853b0270488a412b6ab757904b7f787b/orjson-3.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9683ee9ea0659da64f36574ef675b8a86330c34c19ea75db1fb93c3ff99e0ef", size = 131479, upload-time = "2026-08-14T16:12:06.11Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a6/22e863bbbe8917aa292e33e0db597000f9a07eb5e6f52efed623fa16bae1/orjson-3.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:103b5db66aa53c1f9e88c2524be4f383e831ba7dfd5f9f5af6336a177c622f11", size = 135865, upload-time = "2026-08-14T16:12:07.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/a0/ceb5008914a65e9a19a46a09d94bc67a74d120209fdfa772750023ceb377/orjson-3.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bd57d79aefa3f84eec851d6de7a366795b9345cfaf17f82b4820430a7a5fa241", size = 127843, upload-time = "2026-08-14T16:12:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/3d/61c6b3b84c250cb09cb7229701ff77e4d763773ad7f577d0b6abf2892664/orjson-3.12.0-cp310-cp310-win32.whl", hash = "sha256:3dbce9b6b3074b31a5d5dd322a9c4e5b16f206091ece4194c2e36952847a105e", size = 128293, upload-time = "2026-08-14T16:12:09.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0e/ea0f4a563253b6363195a4f704123c6bfbf156641bd3be5a75de81c5e917/orjson-3.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:3bb17a06f9bd15237b3216c044209fe92597379124018cfc196fbb846cde64df", size = 122216, upload-time = "2026-08-14T16:12:11.261Z" }, + { url = "https://files.pythonhosted.org/packages/75/1a/a7075a8e8b0d3f5097d17ac3099017104b6b7b42012041147995d5b2da05/orjson-3.12.0-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a94f0f0c6fcbb2b5bd9734c57a489c7584a732bbdf04a39e8c83b861e9d03e92", size = 223409, upload-time = "2026-08-14T16:12:12.654Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c2eb3b2900e5597db7841a4c6416ac2d90081bd956b02d4dd1833fa2b96b/orjson-3.12.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:a696529ec96a90d9a5f9570207efe403c8b08f8e4aa2783ee3403511e2fdfa10", size = 124015, upload-time = "2026-08-14T16:12:14.025Z" }, + { url = "https://files.pythonhosted.org/packages/1c/df/b49081766a75b6a37b3d33bdc0a39e492abab8441dd25e3e1998e7b83fcb/orjson-3.12.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:e4ac5059baab4b3acbd99485de019ff8cda0fdf34b61fa74f7197a53db78bfe8", size = 113471, upload-time = "2026-08-14T16:12:15.81Z" }, + { url = "https://files.pythonhosted.org/packages/48/d4/58ea28eeef95c2a27358ed927380a621162cf20bd740bbccf9c3f09a200a/orjson-3.12.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:8e29957429c35bbb5a185a119c523aa2428b7bbf1a293724c7b9375ed8f892a3", size = 129998, upload-time = "2026-08-14T16:12:17.503Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f4/1e82aa2efc9916422d804697876ce433c907a1abd7c7e5c6d3d48565e5f9/orjson-3.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dce0166feb0a737ab84f598c9a338cbc0b764a036617aa686194f53c7eba0c3e", size = 130891, upload-time = "2026-08-14T16:12:18.762Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e1/15169e9d22b59a406264f99d6db387c0b0b12b6357a8a0169917c2a713eb/orjson-3.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9caf3d09f47c3c70c4451ada20ef9bc4a4cdffa26f49862cf0a253b329aae2d5", size = 131285, upload-time = "2026-08-14T16:12:20.251Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/763dbd426290d044ec3e615a05e70adb6d8b6f95bf17dc355c0081a5e8b6/orjson-3.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b9dca132b1fda5565088e65a6b6e742285e0aeceb6fae549fa8863e16c7d3998", size = 135707, upload-time = "2026-08-14T16:12:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/3b2038ed168d22e14182ed715d6963f9c073a83a2ba43cfe918a4fc43c64/orjson-3.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a791f793b287bbc135b8e87c34e35c8bfc693e2a8a620fab1ae682b925f9a32e", size = 127669, upload-time = "2026-08-14T16:12:22.926Z" }, + { url = "https://files.pythonhosted.org/packages/88/ae/b84b3d3e65f5629ada0edcb1d2bccc55d7c5f89d8b981537ecdc3d6f31ec/orjson-3.12.0-cp311-cp311-win32.whl", hash = "sha256:31ed278a36304390adc3eec5d7f6fd593a7c3e99e5a06cd07866396c4b1b4710", size = 128043, upload-time = "2026-08-14T16:12:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/35/24/2ed0e6f51ea3d0af45d807233a851175af75bec83ef5fd0d6a2601904ec0/orjson-3.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:fb2539159dfe8d371914f354360fa50e4a577cc89222a3828b9650a5e5040252", size = 122084, upload-time = "2026-08-14T16:12:25.813Z" }, + { url = "https://files.pythonhosted.org/packages/21/dd/95d25fcfbc9471799ef6bb01c552d64ee5cde93ee40ba2f423dd3442c708/orjson-3.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:61318b6de893c7a9d9f3e5ecbadccbfc26a7eb417ccc7bbf0771de3b4d72f868", size = 127035, upload-time = "2026-08-14T16:12:27.201Z" }, + { url = "https://files.pythonhosted.org/packages/be/4a/295da39c651c2faac8bd351a2a346f0fdedd9d50b847ee9dfc27d2207ef6/orjson-3.12.0-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:aa3e43a6846e91d7bde3d5a9c66090fcd8744f569a9b6cffc5e1ca38f6a461c0", size = 223427, upload-time = "2026-08-14T16:12:28.525Z" }, + { url = "https://files.pythonhosted.org/packages/29/98/758cf90fbeaaafb7f8141bfac75a432099959f3a2f5db93a412e876415d8/orjson-3.12.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:11edb4660a6680abee9788a3a9072208a2c96538cc1322bd79542065229d8e54", size = 123725, upload-time = "2026-08-14T16:12:30.013Z" }, + { url = "https://files.pythonhosted.org/packages/32/b5/5b934d251f8651f7e41df180ad0c57a6e1cabe15c7bd331638413a50ebc9/orjson-3.12.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2d3a9da945a4d96ae758fdaaca56742e6b73b6fd554c5d8876f252a6dad70b83", size = 113375, upload-time = "2026-08-14T16:12:31.209Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d2/37efb5b12a176ce3ced29f4144f20da57d02757f78ce549637dc1b4e1fc8/orjson-3.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:92ffc09e07233a6ab6d4e067f7841edcbcc134cb4812155cf171ea5255a421d7", size = 129983, upload-time = "2026-08-14T16:12:32.721Z" }, + { url = "https://files.pythonhosted.org/packages/50/22/0644b87c73f13e0092df8f35a1fe280d991e5e90072087411e0dd7e44e0c/orjson-3.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf44e374aadde77b1f6109f1030be51433eb61984379852766b6f4e187db7b1e", size = 130629, upload-time = "2026-08-14T16:12:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/8c/57/80b986ebfecd9c6a177ddf1c2319717f0cd8feffb2b78946595a18a2fc88/orjson-3.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1192a7021b6d071aaf909864f6e924d6a2675ca360485b972b8401749311750b", size = 131245, upload-time = "2026-08-14T16:12:35.713Z" }, + { url = "https://files.pythonhosted.org/packages/80/3d/75c5ac5a69161f44492a68fbdde66f4cc4ce48cd5e1fb05918e46f0c8848/orjson-3.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:53c0c474a9d9aff9aebfc0c88de1f28f843d940e6e3a80729abdf6a20274356f", size = 135397, upload-time = "2026-08-14T16:12:37.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/93/4d71f2df314a97ff0d27a4559bf5888fc8406e3c6dec90e92291e3511215/orjson-3.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:532ff8cd4bd59a327a953a7dcde922c7fc25b85e29721bb8633265430d3a3873", size = 127693, upload-time = "2026-08-14T16:12:38.627Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1d/0dbc6be5adfd1730491072fb60beb6bcdf5d7b2596ee41b7fc2e298bfc09/orjson-3.12.0-cp312-cp312-win32.whl", hash = "sha256:a6cf4b18e7de173f209f2084ffbd736dd72389a396326ee80a7022168be232e5", size = 128000, upload-time = "2026-08-14T16:12:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c9/97b1ce0112ebf5e949c775ed5b1755e562233179f3584579673cc24d6378/orjson-3.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:010811c1b69773450a01cef97727a67b223242f350b77d4ca000e59a9ef2155a", size = 122106, upload-time = "2026-08-14T16:12:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/a8/6a/facd8b312e4a0d3a7fa978c7e15821f74a336adf1d65529faec33b48e18b/orjson-3.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:ad29eece0c601737f2a60edc2752a84e7a0785df3efb62e3012834700a5afe0d", size = 126869, upload-time = "2026-08-14T16:12:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/54/cb/d7b78218a987eb8a8ce4eeae0286b1bb679333eb631ea0eeaf6371680bfc/orjson-3.12.0-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9a36ec60f1796f9a3f13e3b98390295e17a1c7c10155b448d264098bf9ee5900", size = 223397, upload-time = "2026-08-14T16:12:44.003Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4a/bc87c45e7ec639d35ebefd62618e01939531ac8e171426606a01bda05914/orjson-3.12.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ad0422b92d5195443a39f80c3bcf731cc2e00f153bd32063a47b73b057bd0f03", size = 123662, upload-time = "2026-08-14T16:12:45.433Z" }, + { url = "https://files.pythonhosted.org/packages/94/ee/c9a4ff3f2dbedbbe9e635d0fa72c8866adede09b6335ef9644f53752f0d8/orjson-3.12.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:5a0fdbc216388f653d3752ff310e710f59253bd4ed6a2bfb3f4f06b84714bbd8", size = 113374, upload-time = "2026-08-14T16:12:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/75/09/3f330a026a796c8b4c97a6f429652a5e912e7065039bf96ed25e42aa7b25/orjson-3.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2eb5c56e534127b2b8fa38d2363c8b1b8190367ee0d1d16c041517d880843b94", size = 130029, upload-time = "2026-08-14T16:12:48.06Z" }, + { url = "https://files.pythonhosted.org/packages/7d/40/094cc53126a3d22f76cdf83b6ea67338bed01d774037621a785aa8e6e5ea/orjson-3.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:784106539f4b9d4b930e0b4eb8d45168507dae001945e71b4675a367f1e5e806", size = 130528, upload-time = "2026-08-14T16:12:49.362Z" }, + { url = "https://files.pythonhosted.org/packages/bc/74/89bb236deb9565f99434b13052bb40ddfcce4adf3afbfa3132ee7e421468/orjson-3.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c680706fc8396d95e7c4c1f9482563f552137aef91b57237a3ad5aaf64629df", size = 131075, upload-time = "2026-08-14T16:12:50.692Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ac/1176360d762c01b5bd34acd56fc098e936c491363d8b6b397ad4aa475547/orjson-3.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:83445adc40cba26d6d621185a45128ce455b766af368cad2ab64b970603a7978", size = 135321, upload-time = "2026-08-14T16:12:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/7a/02/bbd881c8b9276d50b998de38b4e97de8ace1aac940b0ee545aedbf65ed00/orjson-3.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:644d005bc82f917337a95ce270c9f6f92f9834c2bed7b1477572f8db00784222", size = 127472, upload-time = "2026-08-14T16:12:53.517Z" }, + { url = "https://files.pythonhosted.org/packages/8e/02/a0934d7503e6dcbedd6afac3e7f3f8597fd09389949ad94d0f7540e9dbca/orjson-3.12.0-cp313-cp313-win32.whl", hash = "sha256:d8e78d3d93705e3d27cc17cdb209e44d7a8ea203010cac6ce9c7ffc1ae1996f1", size = 128000, upload-time = "2026-08-14T16:12:55.14Z" }, + { url = "https://files.pythonhosted.org/packages/52/87/69f98f8d40faff103a965a5fbb83f08241b01beaf92badb5413fbc9358cc/orjson-3.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b85931be5b6763c31283805c9bdaae1ca03ad9f6f12a15f1cbf6745b907932c2", size = 121841, upload-time = "2026-08-14T16:12:56.507Z" }, + { url = "https://files.pythonhosted.org/packages/e6/07/b83046a4e3cadcc0987d0f160696107c4af706a619b56e4ad01940cadadf/orjson-3.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:6a31348d7dfa64cd9c78bd1f510ff44c48fe64d71094e6b90e364dba3b55949e", size = 126765, upload-time = "2026-08-14T16:12:57.806Z" }, + { url = "https://files.pythonhosted.org/packages/12/9d/3931253e6f3148abf2cbe14830367042a4806b362ea520df2303db188fb9/orjson-3.12.0-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9e6fee342a48760e854d743e7a81534d8e2925a6f46e09f750cf56b50fd1de5d", size = 223391, upload-time = "2026-08-14T16:12:59.184Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0e/b4a4f1e305367245877b967a0bad70fcf001d77c54ac4339a120b66fdae4/orjson-3.12.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:8c3bb86dd10f39b3fbf434b7d5dc7cac77d6fc8ac572ae30a10731ede2c4b647", size = 123659, upload-time = "2026-08-14T16:13:00.548Z" }, + { url = "https://files.pythonhosted.org/packages/96/f3/6782c6fa85e2702bc66be183c3b421486167dcf266ee4dc1403fe3824870/orjson-3.12.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2bb3ce43203936072dd8b4917b01d3aecfc02329bfb42510cb7cfb24708adc9c", size = 113337, upload-time = "2026-08-14T16:13:02.009Z" }, + { url = "https://files.pythonhosted.org/packages/bf/79/b32ab64bacda9d0fa4942ef483bd03cabf0eaf2be819ca9fb7ff610c559d/orjson-3.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:6a2a79c89984dc719817d388c8709e0efc2a2795a934eaa746b4882eb6045adc", size = 130112, upload-time = "2026-08-14T16:13:03.404Z" }, + { url = "https://files.pythonhosted.org/packages/ee/49/6e6142999ca01509219be5e5a9c338a3e5ea011f63e91ff473fbbf3734ed/orjson-3.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f06dd838d1e07d9b1de0932ec0485ec92c4d5f5d1ad4817a656268c3e88be1e1", size = 130520, upload-time = "2026-08-14T16:13:04.798Z" }, + { url = "https://files.pythonhosted.org/packages/49/d0/3745af0a4cc9867784f29722929cec4d10bd1c877cd754b01ba6d96eb21a/orjson-3.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6b11be792c3d2c6a4be2af4ebf97a68d0bf5f580aca6e86a418a354f6cc846a", size = 131053, upload-time = "2026-08-14T16:13:06.14Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/6fe5a22fa478fffb190e65c338c84df5c311ef597b363150a17cc57063c0/orjson-3.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:477ecaf6b9f88f873341b91fcc736119ca81b5e002a9f7f308ff5b4f2ce2a70e", size = 135321, upload-time = "2026-08-14T16:13:07.544Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/b1b0ec30289646a81a76e2dbaae2686b96fcccb7cb0323dc1dd78cbc7875/orjson-3.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3c0683136acdc29afdf88a5bc2f7d3d0e34087788d1d63c0144b805a87a196f", size = 127485, upload-time = "2026-08-14T16:13:08.88Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2b/277404bdcc21c93b112b963655b76443ebfe828f8a3ff1de7d90f8850eb3/orjson-3.12.0-cp314-cp314-win32.whl", hash = "sha256:d39f3f5c3927e2dc0913fe5bbc1a2f6b1b9d1bba1de6358340d0ad0d0c00ca92", size = 128048, upload-time = "2026-08-14T16:13:10.305Z" }, + { url = "https://files.pythonhosted.org/packages/41/2b/395b36fa2b4ce7af70b651d715e88f80d884b2c2b14a6b53e84d554fb5f0/orjson-3.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:0b1ac5bf6609b2716c7954011c5fef6254922df029f45d032ee4ebf5d363cbed", size = 121858, upload-time = "2026-08-14T16:13:11.634Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a3/833e895ff452859eebe75093d26691fe9108f1a7a6a08435d7a5780ea652/orjson-3.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:50fae885cb073eac7556353ff3df93312b0d5137b0a5056b2bb63f97ed9a93c7", size = 126749, upload-time = "2026-08-14T16:13:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/99c8947ece10c17176af9aae85c4948f1d109da77440ec14d87239efaf73/orjson-3.12.0-cp315-cp315-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:01efac2074fffb4cb1ea3fab7861e9d0f2a26913854a972f5ac760525dbdaf6e", size = 223398, upload-time = "2026-08-14T16:13:14.694Z" }, + { url = "https://files.pythonhosted.org/packages/3e/30/cf983fe09f2731420fda097a9f7ef4343f47fa216c228961ad8f6da44f3d/orjson-3.12.0-cp315-cp315-macosx_15_0_arm64.whl", hash = "sha256:ed4ca42bd55955aa34deedcfdfd0e0c31abf51143aae158ae2bc3520b626e517", size = 123655, upload-time = "2026-08-14T16:13:16.221Z" }, + { url = "https://files.pythonhosted.org/packages/11/50/9cb8ae73fa4749dbbc20f617004213b5ff01c20aaeec34c3f31124f2c1d8/orjson-3.12.0-cp315-cp315-manylinux_2_39_aarch64.whl", hash = "sha256:40f92192227505acca4e2533ce565f8e6b9535f7d0d09b0968452f18b7376b38", size = 130515, upload-time = "2026-08-14T16:13:17.601Z" }, + { url = "https://files.pythonhosted.org/packages/9f/0a/adb6ce1a5b5fbf9cb1790f9961bb668a0dd5429aadaf6cee044724681795/orjson-3.12.0-cp315-cp315-manylinux_2_39_armv7l.whl", hash = "sha256:33efefcf5d88eaf400b47e2eba02f91f319bb9951be61ca500b7d536d3f2079d", size = 113327, upload-time = "2026-08-14T16:13:18.927Z" }, + { url = "https://files.pythonhosted.org/packages/51/5c/d17f61581d8dbdde7048f87a330fa24915edec38db4d72b381fec14fbb56/orjson-3.12.0-cp315-cp315-manylinux_2_39_i686.whl", hash = "sha256:8e386b0bc0ddd7cd2056f884b5a0af33592bd01ac66a7ca4b42a65a7e7774a13", size = 130105, upload-time = "2026-08-14T16:13:20.317Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b7/938befcf33bee4704a92ecec6a2731224c539d939bf9429fd39396d28931/orjson-3.12.0-cp315-cp315-manylinux_2_39_x86_64.whl", hash = "sha256:58c58e1de0006ffb580368d6793c36c7b0b021db066479cf281bf5061e732328", size = 131049, upload-time = "2026-08-14T16:13:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/b0/15/cfa2021d64d5aa8bb5c9f604ef375e00ec8b657651b5dd650b1b7ad13df1/orjson-3.12.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:08231552159be266a7269555bd9f7c016aee7d9ad6dab06eb58796c5ccb7101c", size = 135320, upload-time = "2026-08-14T16:13:23.415Z" }, + { url = "https://files.pythonhosted.org/packages/1a/50/3e75dfe357c1e8f9e287c7a5740260ef15bd23a5299eae8d0835dcad5375/orjson-3.12.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a15f9a891bce5f5cc5d210e3ad8614d4d1b489a56448c099d6d2a7168b2d954a", size = 127488, upload-time = "2026-08-14T16:13:24.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/a6/79aed402eb3ab284dc5b4791a7ad62c5875127de01b8e3f04bd92d551298/orjson-3.12.0-cp315-cp315-win32.whl", hash = "sha256:03091c8a64db4be38746597ceea68f33c238e27acd9bfe99fb59420224ae7a55", size = 128048, upload-time = "2026-08-14T16:13:26.217Z" }, + { url = "https://files.pythonhosted.org/packages/64/f7/2723e264aab7248c1ed6ecaad8e5d0cb866c0cffde75442102ffa7491aba/orjson-3.12.0-cp315-cp315-win_amd64.whl", hash = "sha256:2b7bcefb9f40fa242fa6b06377232c048e655747790829609168c01162f60578", size = 121860, upload-time = "2026-08-14T16:13:27.577Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/630c9113ec8996778f1f0304b364b091b9a9db5fef5fdc17cca622f5ea24/orjson-3.12.0-cp315-cp315-win_arm64.whl", hash = "sha256:859fc4196855890150bb08e649b30d2c93b249b3e3edd0d3bb2231abf8aa8adc", size = 126754, upload-time = "2026-08-14T16:13:28.962Z" }, +] + +[[package]] +name = "ormsgpack" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/fa/a91f70829ebccf6387c4946e0a1a109f6ba0d6a28d65f628bedfad94b890/ormsgpack-1.12.2-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c1429217f8f4d7fcb053523bbbac6bed5e981af0b85ba616e6df7cce53c19657", size = 378262, upload-time = "2026-01-18T20:55:22.284Z" }, + { url = "https://files.pythonhosted.org/packages/5f/62/3698a9a0c487252b5c6a91926e5654e79e665708ea61f67a8bdeceb022bf/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f13034dc6c84a6280c6c33db7ac420253852ea233fc3ee27c8875f8dd651163", size = 203034, upload-time = "2026-01-18T20:55:53.324Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/f716f64edc4aec2744e817660b317e2f9bb8de372338a95a96198efa1ac1/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59f5da97000c12bc2d50e988bdc8576b21f6ab4e608489879d35b2c07a8ab51a", size = 210538, upload-time = "2026-01-18T20:55:20.097Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/a436be9ce27d693d4e19fa94900028067133779f09fc45776db3f689c822/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e4459c3f27066beadb2b81ea48a076a417aafffff7df1d3c11c519190ed44f2", size = 212401, upload-time = "2026-01-18T20:55:46.447Z" }, + { url = "https://files.pythonhosted.org/packages/10/c5/cde98300fd33fee84ca71de4751b19aeeca675f0cf3c0ec4b043f40f3b76/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a1c460655d7288407ffa09065e322a7231997c0d62ce914bf3a96ad2dc6dedd", size = 387080, upload-time = "2026-01-18T20:56:00.884Z" }, + { url = "https://files.pythonhosted.org/packages/6a/31/30bf445ef827546747c10889dd254b3d84f92b591300efe4979d792f4c41/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:458e4568be13d311ef7d8877275e7ccbe06c0e01b39baaac874caaa0f46d826c", size = 482346, upload-time = "2026-01-18T20:55:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f5/e1745ddf4fa246c921b5ca253636c4c700ff768d78032f79171289159f6e/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8cde5eaa6c6cbc8622db71e4a23de56828e3d876aeb6460ffbcb5b8aff91093b", size = 425178, upload-time = "2026-01-18T20:55:27.106Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a2/e6532ed7716aed03dede8df2d0d0d4150710c2122647d94b474147ccd891/ormsgpack-1.12.2-cp310-cp310-win_amd64.whl", hash = "sha256:dc7a33be14c347893edbb1ceda89afbf14c467d593a5ee92c11de4f1666b4d4f", size = 117183, upload-time = "2026-01-18T20:55:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401, upload-time = "2026-01-18T20:56:10.101Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082, upload-time = "2026-01-18T20:56:12.047Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346, upload-time = "2026-01-18T20:56:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181, upload-time = "2026-01-18T20:55:37.83Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182, upload-time = "2026-01-18T20:55:31.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464, upload-time = "2026-01-18T20:55:38.811Z" }, + { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" }, + { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" }, + { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" }, + { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" }, + { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" }, +] + +[[package]] +name = "overrides" +version = "7.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pdfminer-six" +version = "20260107" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/a4/5cec1112009f0439a5ca6afa8ace321f0ab2f48da3255b7a1c8953014670/pdfminer_six-20260107.tar.gz", hash = "sha256:96bfd431e3577a55a0efd25676968ca4ce8fd5b53f14565f85716ff363889602", size = 8512094, upload-time = "2026-01-07T13:29:12.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/8b/28c4eaec9d6b036a52cb44720408f26b1a143ca9bce76cc19e8f5de00ab4/pdfminer_six-20260107-py3-none-any.whl", hash = "sha256:366585ba97e80dffa8f00cebe303d2f381884d8637af4ce422f1df3ef38111a9", size = 6592252, upload-time = "2026-01-07T13:29:10.742Z" }, +] + +[[package]] +name = "pdfplumber" +version = "0.11.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pdfminer-six" }, + { name = "pillow" }, + { name = "pypdfium2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/56/6f450312ba05a27d7713b73857c1a25100dbda04fbc1331b13fb227a607d/pdfplumber-0.11.10.tar.gz", hash = "sha256:b95b2d28c66efb0a794a83b88c6c6aea5987532a445d20a1cbcfa657022e6e57", size = 102892, upload-time = "2026-06-15T03:31:31.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/9a/07d658e1e7fad860f1c541ab941348125dbdab773be3a0afaf32361866c7/pdfplumber-0.11.10-py3-none-any.whl", hash = "sha256:7741ea81bf165b474b153e6789d10d18e06b6ddcf3ec84289c3ef2fed6802580", size = 60047, upload-time = "2026-06-15T03:31:29.702Z" }, +] + +[[package]] +name = "pendulum" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/72/9a51afa0a822b09e286c4cb827ed7b00bc818dac7bd11a5f161e493a217d/pendulum-3.2.0.tar.gz", hash = "sha256:e80feda2d10fa3ff8b1526715f7d33dcb7e08494b3088f2c8a3ac92d4a4331ce", size = 86912, upload-time = "2026-01-30T11:22:24.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/a4/934d8c97851bda5a034b0fd0512689173c8ca8cb3b87ebf8e5c1364d57f3/pendulum-3.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:4a6bf778c6b42830b001c714dae5b9dad78da38e2e08203a4b0f5d53f8fa5e63", size = 338065, upload-time = "2026-01-30T11:20:36.467Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/2091275a9025f9b9ef9bf72ae386786a9b03af9515f5e2f5befb012ec91f/pendulum-3.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:625209bb7133d990905e8935e1c04f0a82315ae777b67910969b16f665d62c0b", size = 327426, upload-time = "2026-01-30T11:20:38.506Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ba/efc999e5b441a470df28964531c3ee7fce90dd2c510969132bba5897084e/pendulum-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6f1d8641e8bd48b9b6f77f96fd498d3ecec63611ba8e7207e63936307846042", size = 340362, upload-time = "2026-01-30T11:20:40.329Z" }, + { url = "https://files.pythonhosted.org/packages/8c/71/bc88d786f0a10fcfdc5a0bac75c6cdb38df13ee09bc04d2e6ac0d3fd7948/pendulum-3.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a8d4212b1577ee3a034d18b360a9afa55bfc72789aeb805353be8b2ac132035", size = 373937, upload-time = "2026-01-30T11:20:42.242Z" }, + { url = "https://files.pythonhosted.org/packages/86/fb/48262b5b31fdfd68221cb92ab228657d0cd628fb35eca1a6f3aedad5ea09/pendulum-3.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e398d9e3db42d17f0c2cd39663c1c873ea6f11763ed6d126e2dcc92fc340d0dc", size = 379391, upload-time = "2026-01-30T11:20:43.736Z" }, + { url = "https://files.pythonhosted.org/packages/ae/72/cecb1710c36c6fe61e545050607c2050a2af0b991cf1a3d83981dfd895e8/pendulum-3.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04310463879a8d84534756ef9820d433e88b879203b6e10a5b416899dc05e7f1", size = 348433, upload-time = "2026-01-30T11:20:45.207Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/ec00008ba2f3298047a32b53588550a7ead84c579e7d7e4396474ab2f1ef/pendulum-3.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5b4f7491951c11bbdb20893817352c9140d31d1ae333839c34c0bca081a50a86", size = 517623, upload-time = "2026-01-30T11:20:46.741Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6f/541730ac4679e7f7ff5786aed21865c4f4a7d9b1d2693cfdbb891bdd5a5a/pendulum-3.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ffc169ad7595228d4dfc44d4e016846ff1bb5873b9f7ec70b0b1b51da0c77b3f", size = 561237, upload-time = "2026-01-30T11:20:48.252Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c1/165f10f2e37978caf92a1dca71726e7cd5d8de4039f9f4a6d1994a9b8d7f/pendulum-3.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:446f63d84ef21281844ceb45141536d3aabe291a821b6505e21a0d0e3ea95d67", size = 260733, upload-time = "2026-01-30T11:20:50.249Z" }, + { url = "https://files.pythonhosted.org/packages/c4/27/a4be6ec12161b503dd036f8d7cc57f8626170ae31bb298038be9af0001ce/pendulum-3.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5d775cc608c909ad415c8e789c84a9f120bb6a794c4215b2d8d910893cf0ec6a", size = 337923, upload-time = "2026-01-30T11:20:51.61Z" }, + { url = "https://files.pythonhosted.org/packages/59/e1/2a214e18355ec2a6ce3f683a97eecdb6050866ff3a6cf165d411450aeb1b/pendulum-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8de794a7f665aebc8c1ba4dd4b05ab8fe1a36ce9c0498366adf1d1edd79b2686", size = 327379, upload-time = "2026-01-30T11:20:53.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/01/7392e58ebc1d9e70b987dc8bb0c89710b47ac8125067efe7aa4c420b616f/pendulum-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bac7df7696e1c942e17c0556b3a7bcdd1d7aa5b24faee7620cb071e754a0622", size = 340115, upload-time = "2026-01-30T11:20:54.635Z" }, + { url = "https://files.pythonhosted.org/packages/ef/33/80de84c5ca1a3e4f7f3b75090c9b61b6dbb6d095e302ee592cebbaf0bbfb/pendulum-3.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db0f6a8a04475d9cba26ce701e7d66d266fd97227f2f5f499270eba04be1c7e9", size = 373969, upload-time = "2026-01-30T11:20:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/f7b4c1818927ab394a2a0a9b7011f360a0a75839a22678833c5bc0a84183/pendulum-3.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c352c63c1ff05f2198409b28498d7158547a8be23e1fbd4aa2cf5402fb239b55", size = 379058, upload-time = "2026-01-30T11:20:57.618Z" }, + { url = "https://files.pythonhosted.org/packages/36/94/9947cf710620afcc68751683f2f8de88d902505e7c13c0349d7e9d362f97/pendulum-3.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de8c1ad1d1aa7d4ceae341528bab35a0f8c88a5aa63f2f5d84e16b517d1b32c2", size = 348403, upload-time = "2026-01-30T11:20:59.56Z" }, + { url = "https://files.pythonhosted.org/packages/6f/12/0e6ba0bb00fa57907af2a3fca8643bded5dba1e87072d50673776a0d6ed2/pendulum-3.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1ba955511c12fec2252038b0c866c25c0c30b720bf74d3023710f121e42b1498", size = 517457, upload-time = "2026-01-30T11:21:01.602Z" }, + { url = "https://files.pythonhosted.org/packages/c6/fe/dae5fbfe67bd41d943def0ad8f1e7f6988aa8e527255e433cd7c494f9ad5/pendulum-3.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4115bf364a2ec6d5ddc476751ceaa4164a04f2c15589f0d29aa210ddb784b15d", size = 561103, upload-time = "2026-01-30T11:21:03.924Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a0/8f646160b98abfc19152505af19bd643a4279ec2bdbe0959f16b7025fc6b/pendulum-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:4151a903356413fdd9549de0997b708fb95a214ed97803ffb479ffd834088378", size = 260595, upload-time = "2026-01-30T11:21:05.495Z" }, + { url = "https://files.pythonhosted.org/packages/79/01/feead7af9ded7a13f2d798fb6573e70f469113eafcd8cc8f59671584ca3e/pendulum-3.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:acfdee9ddc56053cb7c8c075afbfde0857322d09e56a56195b9cd127fae87e4c", size = 255382, upload-time = "2026-01-30T11:21:06.847Z" }, + { url = "https://files.pythonhosted.org/packages/41/56/dd0ea9f97d25a0763cda09e2217563b45714786118d8c68b0b745395d6eb/pendulum-3.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bf0b489def51202a39a2a665dcc4162d5e46934a740fe4c4fe3068979610156c", size = 337830, upload-time = "2026-01-30T11:21:08.298Z" }, + { url = "https://files.pythonhosted.org/packages/cf/98/83d62899bf7226fc12396de4bc1fb2b5da27e451c7c60790043aaf8b4731/pendulum-3.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:937a529aa302efa18dcf25e53834964a87ffb2df8f80e3669ab7757a6126beaf", size = 327574, upload-time = "2026-01-30T11:21:09.715Z" }, + { url = "https://files.pythonhosted.org/packages/76/fa/ff2aa992b23f0543c709b1a3f3f9ed760ec71fd02c8bb01f93bf008b52e4/pendulum-3.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85c7689defc65c4dc29bf257f7cca55d210fabb455de9476e1748d2ab2ae80d7", size = 339891, upload-time = "2026-01-30T11:21:11.089Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4e/25b4fa11d19503d50d7b52d7ef943c0f20fd54422aaeb9e38f588c815c50/pendulum-3.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e216e5a412563ea2ecf5de467dcf3d02717947fcdabe6811d5ee360726b02b", size = 373726, upload-time = "2026-01-30T11:21:12.493Z" }, + { url = "https://files.pythonhosted.org/packages/4f/30/0acad6396c4e74e5c689aa4f0b0c49e2ecdcfce368e7b5bf35ca1c0fc61a/pendulum-3.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a2af22eeec438fbaac72bb7fba783e0950a514fba980d9a32db394b51afccec", size = 379827, upload-time = "2026-01-30T11:21:14.08Z" }, + { url = "https://files.pythonhosted.org/packages/3a/f7/e6a2fdf2a23d59b4b48b8fa89e8d4bf2dd371aea2c6ba8fcecec20a4acb9/pendulum-3.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3159cceb54f5aa8b85b141c7f0ce3fac8bdd1ffdc7c79e67dca9133eac7c4d11", size = 348921, upload-time = "2026-01-30T11:21:15.816Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f2/c15fa7f9ad4e181aa469b6040b574988bd108ccdf4ae509ad224f9e4db44/pendulum-3.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c39ea5e9ffa20ea8bae986d00e0908bd537c8468b71d6b6503ab0b4c3d76e0ea", size = 517188, upload-time = "2026-01-30T11:21:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/47/c7/5f80b12ee88ec26e930c3a5a602608a63c29cf60c81a0eb066d583772550/pendulum-3.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e5afc753e570cce1f44197676371f68953f7d4f022303d141bb09f804d5fe6d7", size = 561833, upload-time = "2026-01-30T11:21:19.232Z" }, + { url = "https://files.pythonhosted.org/packages/90/15/1ac481626cb63db751f6281e294661947c1f0321ebe5d1c532a3b51a8006/pendulum-3.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:fd55c12560816d9122ca2142d9e428f32c0c083bf77719320b1767539c7a3a3b", size = 258725, upload-time = "2026-01-30T11:21:20.558Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/50b0398d7d027eb70a3e1e336de7b6e599c6b74431cb7d3863287e1292bb/pendulum-3.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:faef52a7ed99729f0838353b956f3fabf6c550c062db247e9e2fc2b48fcb9457", size = 253089, upload-time = "2026-01-30T11:21:22.497Z" }, + { url = "https://files.pythonhosted.org/packages/27/8c/400c8b8dbd7524424f3d9902ded64741e82e5e321d1aabbd68ade89e71cf/pendulum-3.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:addb0512f919fe5b70c8ee534ee71c775630d3efe567ea5763d92acff857cfc3", size = 337820, upload-time = "2026-01-30T11:21:24.305Z" }, + { url = "https://files.pythonhosted.org/packages/59/38/7c16f26cc55d9206d71da294ce6857d0da381e26bc9e0c2a069424c2b173/pendulum-3.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3aaa50342dc174acebdc21089315012e63789353957b39ac83cac9f9fc8d1075", size = 327551, upload-time = "2026-01-30T11:21:25.747Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cd/f36ec5d56d55104232380fdbf84ff53cc05607574af3cbdc8a43991ac8a7/pendulum-3.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:927e9c9ab52ff68e71b76dd410e5f1cd78f5ea6e7f0a9f5eb549aea16a4d5354", size = 339894, upload-time = "2026-01-30T11:21:27.229Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/b9a1e546519c3a92d5bc17787cea925e06a20def2ae344fa136d2fc40338/pendulum-3.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:249d18f5543c9f43aba3bd77b34864ec8cf6f64edbead405f442e23c94fce63d", size = 373766, upload-time = "2026-01-30T11:21:28.642Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a6/6471ab87ae2260594501f071586a765fc894817043b7d2d4b04e2eff4f31/pendulum-3.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c644cc15eec5fb02291f0f193195156780fd5a0affd7a349592403826d1a35e", size = 379837, upload-time = "2026-01-30T11:21:30.637Z" }, + { url = "https://files.pythonhosted.org/packages/0d/79/0ba0c14e862388f7b822626e6e989163c23bebe7f96de5ec4b207cbe7c3d/pendulum-3.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:063ab61af953bb56ad5bc8e131fd0431c915ed766d90ccecd7549c8090b51004", size = 348904, upload-time = "2026-01-30T11:21:32.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/34/df922c7c0b12719589d4954bfa5bdca9e02bcde220f5c5c1838a87118960/pendulum-3.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:26a3ae26c9dd70a4256f1c2f51addc43641813574c0db6ce5664f9861cd93621", size = 517173, upload-time = "2026-01-30T11:21:34.428Z" }, + { url = "https://files.pythonhosted.org/packages/87/ec/3b9e061eeee97b72a47c1434ee03f6d85f0284d9285d92b12b0fff2d19ac/pendulum-3.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:2b10d91dc00f424444a42f47c69e6b3bfd79376f330179dc06bc342184b35f9a", size = 561744, upload-time = "2026-01-30T11:21:35.861Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7e/f12fdb6070b7975c1fcfa5685dbe4ab73c788878a71f4d1d7e3c87979e37/pendulum-3.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:63070ff03e30a57b16c8e793ee27da8dac4123c1d6e0cf74c460ce9ee8a64aa4", size = 258746, upload-time = "2026-01-30T11:21:37.782Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/5abd872056357f069ae34a9b24a75ac58e79092d16201d779a8dd31386bb/pendulum-3.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:c8dde63e2796b62070a49ce813ce200aba9186130307f04ec78affcf6c2e8122", size = 253028, upload-time = "2026-01-30T11:21:39.381Z" }, + { url = "https://files.pythonhosted.org/packages/82/99/5b9cc823862450910bcb2c7cdc6884c0939b268639146d30e4a4f55eb1f1/pendulum-3.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c17ac069e88c5a1e930a5ae0ef17357a14b9cc5a28abadda74eaa8106d241c8e", size = 338281, upload-time = "2026-01-30T11:21:40.812Z" }, + { url = "https://files.pythonhosted.org/packages/cd/3a/64a35260f6ac36c0ad50eeb5f1a465b98b0d7603f79a5c2077c41326d639/pendulum-3.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e1fbb540edecb21f8244aebfb05a1f2333ddc6c7819378c099d4a61cc91ae93c", size = 328030, upload-time = "2026-01-30T11:21:42.778Z" }, + { url = "https://files.pythonhosted.org/packages/da/6b/1140e09310035a2afb05bb90a2b8fbda9d3222e03b92de9533123afe6b65/pendulum-3.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8c67fb9a1fe8fc1adae2cc01b0c292b268c12475b4609ff4aed71c9dd367b4d", size = 340206, upload-time = "2026-01-30T11:21:44.148Z" }, + { url = "https://files.pythonhosted.org/packages/52/4a/a493de56cbc24a64b21ac6ba98513a9ec5c67daa3dba325e39a8e53f30d8/pendulum-3.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:baa9a66c980defda6cfe1275103a94b22e90d83ebd7a84cc961cee6cbd25a244", size = 373976, upload-time = "2026-01-30T11:21:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4c/f083c4fd1a161d4ab218680cc906338c541497b3098373f2241f58c429cb/pendulum-3.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef8f783fa7a14973b0596d8af2a5b2d90858a55030e9b4c6885eb4284b88314f", size = 380075, upload-time = "2026-01-30T11:21:46.959Z" }, + { url = "https://files.pythonhosted.org/packages/57/b6/333a0fcb33bf15eb879a46a11ce6300c1698a141e689665fe430783ff8d6/pendulum-3.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7d2e9bfb065727d8676e7ada3793b47a24349500a5e9637404355e482c822be", size = 349026, upload-time = "2026-01-30T11:21:48.271Z" }, + { url = "https://files.pythonhosted.org/packages/43/1a/dfb526ec0cba1e7cd6a5e4f4dd64a6ada7428d1449c54b15f7b295f6e122/pendulum-3.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:55d7ba6bb74171c3ee409bf30076ee3a259a3c2bb147ac87ebb76aaa3cf5d3a2", size = 517395, upload-time = "2026-01-30T11:21:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/c9/37/b4f2b5f1200351c4869b8b46ad5c21019e3dbe0417f5867ae969fad7b5fe/pendulum-3.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:a50d8cf42f06d3d8c3f8bb2a7ac47fa93b5145e69de6a7209be6a47afdd9cf76", size = 561926, upload-time = "2026-01-30T11:21:51.698Z" }, + { url = "https://files.pythonhosted.org/packages/a0/9e/567376582da58f5fe8e4f579db2bcfbf243cf619a5825bdf1023ad1436b3/pendulum-3.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e5bbb92b155cd5018b3cf70ee49ed3b9c94398caaaa7ed97fe41e5bb5a968418", size = 258817, upload-time = "2026-01-30T11:21:53.074Z" }, + { url = "https://files.pythonhosted.org/packages/95/67/dfffd7eb50d67fa821cd4d92cf71575ead6162930202bc40dfcedf78c38c/pendulum-3.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:d53134418e04335c3029a32e9341cccc9b085a28744fb5ee4e6a8f5039363b1a", size = 253292, upload-time = "2026-01-30T11:21:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0d/d5ac8468a1b40f09a62d6e91654088de432367907579dd161c0fb1bdf222/pendulum-3.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9585594d32faa71efa5a78f576f1ee4f79e9c5340d7c6f0cd6c5dfe725effaaa", size = 338760, upload-time = "2026-01-30T11:22:12.225Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e5/7fa8c8be6caac8e0be78fbe7668df571f44820ed779cb3736fab645fcba8/pendulum-3.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:26401e2de77c437e8f3b6160c08c6c5d45518d906f8f9b48fd7cb5aa0f4e2aff", size = 328333, upload-time = "2026-01-30T11:22:13.811Z" }, + { url = "https://files.pythonhosted.org/packages/ad/78/73a1031b7d1bf7986e8e655cea3f018164b3470aecfea25a4074e77dda73/pendulum-3.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:637e65af042f383a2764a886aa28ccc6f853bf7a142df18e41c720542934c13b", size = 340841, upload-time = "2026-01-30T11:22:15.278Z" }, + { url = "https://files.pythonhosted.org/packages/49/40/4e36e9074e92b0164c088b9ada3c02bfea386d83e24fa98b30fe9b6e61a8/pendulum-3.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6e46c28f4d067233c4a4c42748f4ffa641d9289c09e0e81488beb6d4b3fab51", size = 348959, upload-time = "2026-01-30T11:22:16.718Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/8bf7fcb91b526e1efe17d047faa845709b88800fff915ff848ff26054293/pendulum-3.2.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:71d46bcc86269f97bfd8c5f1475d55e717696a0a010b1871023605ca94624031", size = 518102, upload-time = "2026-01-30T11:22:18.2Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b0/a36c468d2d0dec62ddea7c5e4177e93abb12f48ac90f09f24d0581c5189f/pendulum-3.2.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5cd956d4176afc7bfe8a91bf3f771b46ff8d326f6c5bf778eb5010eb742ebba6", size = 561884, upload-time = "2026-01-30T11:22:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4d/dad105261898907bf806cabca53d3878529a9fa2c0d5d7f95f2035246fc2/pendulum-3.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:39ef129d7b90aab49708645867abdd207b714ba7bff12dae549975b0aca09716", size = 261236, upload-time = "2026-01-30T11:22:21.059Z" }, + { url = "https://files.pythonhosted.org/packages/02/fb/d65db067a67df7252f18b0cb7420dda84078b9e8bfb375215469c14a50be/pendulum-3.2.0-py3-none-any.whl", hash = "sha256:f3a9c18a89b4d9ef39c5fa6a78722aaff8d5be2597c129a3b16b9f40a561acf3", size = 114111, upload-time = "2026-01-30T11:22:22.361Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/d7/e7bfbc86e9f99ff7807e24de7703f032e9c9ba80bb355cf26e0e9bc5a75e/platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab", size = 33050, upload-time = "2026-08-13T22:43:27.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/a9/c34aebedd3a4c9afe5101b1b8713710b3fec18087c8a36c35d2f909861bd/platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7", size = 23491, upload-time = "2026-08-13T22:43:26.121Z" }, +] + +[[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 = "portalocker" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/f8/969e6f280201b40b31bcb62843c619f343dcc351dff83a5891530c9dd60e/portalocker-2.7.0.tar.gz", hash = "sha256:032e81d534a88ec1736d03f780ba073f047a06c478b06e2937486f334e955c51", size = 20183, upload-time = "2023-01-18T23:36:14.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/df/d4f711d168524f5aebd7fb30969eaa31e3048cf8979688cde3b08f6e5eb8/portalocker-2.7.0-py2.py3-none-any.whl", hash = "sha256:a07c5b4f3985c3cf4798369631fb7011adb498e2a46d8440efc75a8f29a0f983", size = 15502, upload-time = "2023-01-18T23:36:12.849Z" }, +] + +[[package]] +name = "posthog" +version = "5.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "distro" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/20/60ae67bb9d82f00427946218d49e2e7e80fb41c15dc5019482289ec9ce8d/posthog-5.4.0.tar.gz", hash = "sha256:701669261b8d07cdde0276e5bc096b87f9e200e3b9589c5ebff14df658c5893c", size = 88076, upload-time = "2025-06-20T23:19:23.485Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/98/e480cab9a08d1c09b1c59a93dade92c1bb7544826684ff2acbfd10fcfbd4/posthog-5.4.0-py3-none-any.whl", hash = "sha256:284dfa302f64353484420b52d4ad81ff5c2c2d1d607c4e2db602ac72761831bd", size = 105364, upload-time = "2025-06-20T23:19:22.001Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/56/030b7b4719d53085722893e0009dffb9236aa10bca1b12121bdc5626ef16/propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b", size = 93417, upload-time = "2026-05-08T20:59:15.597Z" }, + { url = "https://files.pythonhosted.org/packages/1a/55/1140a8e067b8ec093a18a4ae7bb0045d9db65da38a08618ddc5e2f1994aa/propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c", size = 53847, upload-time = "2026-05-08T20:59:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/20/42/0e7443c90310498561addf346e7d57fe3c6ba1914e1ba938b5464c7bbfd2/propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb", size = 53512, upload-time = "2026-05-08T20:59:18.64Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/cf51a71bab2009517d1a7f0ee07657e3bd446c4d69f67e6966cf17bcf956/propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e", size = 58068, upload-time = "2026-05-08T20:59:20.683Z" }, + { url = "https://files.pythonhosted.org/packages/b7/43/39b6bdee9699fa1e1641c519feeb64a67e2a9f93bb465c70776b37a7333f/propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e", size = 61020, upload-time = "2026-05-08T20:59:22.112Z" }, + { url = "https://files.pythonhosted.org/packages/26/0b/843726fbb0a29a8c5684fdb25971823638399f31e52e9d1f06a02dc9aa6b/propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b", size = 62732, upload-time = "2026-05-08T20:59:23.805Z" }, + { url = "https://files.pythonhosted.org/packages/39/6e/899fed76dc1942b8a64193a4f059d7f1a2c7ef65085e8a9366ed8ec0d199/propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d", size = 60140, upload-time = "2026-05-08T20:59:25.389Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/3da4be9b5b879219ad234aa535b3dd4a080ed1ad48d3a73ca07a9e798f22/propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d", size = 60400, upload-time = "2026-05-08T20:59:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/60/2f/09b72b874a9aa0044faf52a69807a6ed618e267ceaa9ec4a63195fa5b504/propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0", size = 58155, upload-time = "2026-05-08T20:59:28.48Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/97489848c54c95578045473954f10956d619ce6a09e7ac137b71cdcb698b/propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b", size = 57037, upload-time = "2026-05-08T20:59:30.146Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/6c695285ccfc49012743ee9c98212b8c5dd0aed7b63cfd816d4a0f7a1601/propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf", size = 61103, upload-time = "2026-05-08T20:59:31.626Z" }, + { url = "https://files.pythonhosted.org/packages/98/a9/1e500401ca593b0bdb6bf75a70bc2d723835fd53360edff6af70692c7546/propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf", size = 60394, upload-time = "2026-05-08T20:59:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/1f/87/f638b6e375eae0f30a1a2325d8b34fd85fdc785bb9960cf805f3bf1ec69a/propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e", size = 63084, upload-time = "2026-05-08T20:59:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/18/884573f5d97b6d9eba68de759a82c901b7e39d7904d30f7b8d58d42d2a12/propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274", size = 60999, upload-time = "2026-05-08T20:59:38.481Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1a/c3915eb059ceec9e758a56e4cfd955292bc0f201be2176a46b76d94b303a/propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe", size = 39036, upload-time = "2026-05-08T20:59:40.323Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/1dfd5607501a602d19c1c449d2d193b7d1c611f9246b4059026a1189a80e/propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d", size = 42190, upload-time = "2026-05-08T20:59:42.232Z" }, + { url = "https://files.pythonhosted.org/packages/57/93/f71588ad08b3e6f4b555b5ef215808a3c02b042d0151ad82fa6f15be677a/propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5", size = 38545, upload-time = "2026-05-08T20:59:44.087Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[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 = "pyarrow" +version = "25.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/e3/27f57f80141379d60defe6703eb50a707325706f07fedfd1312c7a751995/pyarrow-25.0.1.tar.gz", hash = "sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a", size = 1201653, upload-time = "2026-08-10T12:40:53.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/3e/5cd70becb51e1d044c54ba5e627424a6e87df5b98008cbd22cc6abd409ca/pyarrow-25.0.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:0b1edbb2f385a6a65e9711b62ba86ac54a7816a3f8d17bb3e8a5929d65fb2485", size = 35954271, upload-time = "2026-08-10T12:36:33.857Z" }, + { url = "https://files.pythonhosted.org/packages/64/be/17599e086df264ea7dc221d1101e3131e181e00da428a2f9bd0358f0d06b/pyarrow-25.0.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:a4dd8bf99a8fac133efc0ed6a92f5fddbe2adba0d0f6dd720e39ba9855cea85c", size = 37647543, upload-time = "2026-08-10T12:36:39.486Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/e138b451fd3970a6eda4599f68ae3b2b32b661bc958de3239d54a0bf6575/pyarrow-25.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:bddd0c4f7630c2a3ddf6347c1bdaa79d97bcf6bd445f9e60c816b7d77c85a5ae", size = 46837120, upload-time = "2026-08-10T12:36:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/57/5c/f8fc0eb2de03464a557d5a4d0c15e972d73362414696618833b771f7eddd/pyarrow-25.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a4d6d5e9a3d1879a97c08ded0c797579b7965eafd0f0c26c30b45ccc06db939b", size = 50066460, upload-time = "2026-08-10T12:36:53.702Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d1/0dd64fd06de0333b808a02f60981635f067b71aad3a30698a9a104fae778/pyarrow-25.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:514ddb60285631af068875550c90eddc181db3e8e63a032b1559be189e82f056", size = 49937892, upload-time = "2026-08-10T12:37:00.349Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3c/f89d1bd76d5f3284c2a44d7d7ebbd8204535e5ae2b41f4077069b4ff2ec6/pyarrow-25.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cab40b1edfef0262e0e5251aa2c58d75630f24d06dd7794480243acc001a1d7d", size = 53107240, upload-time = "2026-08-10T12:37:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/67/67/b554a8e09f3f3decccf405eb8fbe86696321cbcb5b62d18b4a5057a4c113/pyarrow-25.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:60e89d8f13861a1f7f8d950fa54aebb8023b30734d0ac51ffa80beabe2df4bba", size = 27848683, upload-time = "2026-08-10T12:37:12.058Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8b/0d23b47702fcfe8b3618d5292035099675c5a1c48258932350c08020f7b5/pyarrow-25.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:51093dd9e10325fbdb3c10a2ae7c4806e5c822d94e74ae4938b26524a3323fee", size = 35946180, upload-time = "2026-08-10T12:37:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/d8/17/707d17a5476c55a9541fde0db8213ac30979a792864d72415f176ba50c45/pyarrow-25.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:eb6203482ff3746a5632303a7279ae0b5a304c46985b49ed1378cb350ea6728d", size = 37644787, upload-time = "2026-08-10T12:37:25.795Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b2/cdc98ecf1a6408280bc3a6a07054cdd99a3f4670acc0545d383ce113e87d/pyarrow-25.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:880523be3d29efcf83d3998835d206118ccf35e3871dbd2fb60408cf6b007a80", size = 46834633, upload-time = "2026-08-10T12:37:33.604Z" }, + { url = "https://files.pythonhosted.org/packages/c8/6e/d3fafc41f378b2c65be43b827798c0fae42049a641c8526633ed3eb573e2/pyarrow-25.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:25f8720bf6387d5dc2ebd2622112de630760419e4b66134405dd24110d15f37e", size = 50065507, upload-time = "2026-08-10T12:37:40.565Z" }, + { url = "https://files.pythonhosted.org/packages/d5/12/8d0698954b8c3001844a898e0a6900bebe83d7ee40c11195174c5122f324/pyarrow-25.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4facd65742a024a4a366328a1d2292062d72d6e023c1b7dda8d4c37544933a25", size = 49955690, upload-time = "2026-08-10T12:37:46.644Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/1ecb936ac6409e90a34d58eea1c7cec09a9ae6d2141b9e49ad01a2b1ea47/pyarrow-25.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa0559502e1cd6254d6814614085dd9c5a3dd0419362978a936a3f68a9e5c3df", size = 53128198, upload-time = "2026-08-10T12:37:52.531Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/5236033550633c9b7377b2a53660b2bbb06cb06dc09c4356332d67643ca1/pyarrow-25.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:62cd0d785b8aa6675ee355f9fc02252a340f4441257c42674937826fd7594325", size = 27857263, upload-time = "2026-08-10T12:37:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e2/9ab15b88cbfac28e16419ce5439ec29234c5172cb8259301b4ba639bdec0/pyarrow-25.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:df961f2e7ae9cf496459259d798652c70625f6c080650d6952f8c04053c58ee9", size = 35861559, upload-time = "2026-08-10T12:38:02.567Z" }, + { url = "https://files.pythonhosted.org/packages/58/79/a0036dbe1eabe1f73127427342f1d99982584c4a2cde2651d6c93499c6f6/pyarrow-25.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:cc4aa407fde9fc660be3939e49ea31f50f3e9fec17c0ec63159f7711edd3efc9", size = 37628383, upload-time = "2026-08-10T12:38:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/13/49/d93a57d375f4bf0cf82913dd6bb54acafde83dd993be2282c81ac5616cad/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:4340f0ba6c1d2e13f21658de1d7c662ca2545018568d0030a1e9afca159d87e3", size = 46820190, upload-time = "2026-08-10T12:38:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/60/c9/711ca85d79f1ec98f29a5eae2b051e25b4ecec5de3e3c0e2d5c5dcb15664/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5389cdf79447ed1515c9e31620e6e1e2302249564d603f2ad727d4f6d313e4c3", size = 50102437, upload-time = "2026-08-10T12:38:22.487Z" }, + { url = "https://files.pythonhosted.org/packages/80/53/8fb8359ff17cfb6263a1cf3ebf7caec9fe197de118719e84fcb1d0618026/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d51592cb7561e87877c506113e7adbf1342ab579e6c21f0ef44b8ba41cb74c80", size = 49942424, upload-time = "2026-08-10T12:38:28.755Z" }, + { url = "https://files.pythonhosted.org/packages/e8/83/4e5ae02a9341571b18a6fca380ac7a58ce6ddae7ab3c060208c0a1e79f02/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6109c94d8b9f3b17a041daca16cacb2f651ad8f1ef70a4232c2c0f37a23da2a8", size = 53144206, upload-time = "2026-08-10T12:38:34.862Z" }, + { url = "https://files.pythonhosted.org/packages/65/ee/197cbf47e49f83e6ebeb946a5259a48a638dea27ac774db42fe78022179d/pyarrow-25.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:8858d7bfc22e3f51529aeaa4077225029724623e4595dc9eff8c793935c34140", size = 27953934, upload-time = "2026-08-10T12:38:39.808Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8d/8f271a7a034c834910ec925d56fa4b29733b1380f5289419f5aaa3b02777/pyarrow-25.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:c7c534ec03c358a76ea3e505e74c1b6aef290af90c444dfd092dbfe23e755b85", size = 35855328, upload-time = "2026-08-10T12:38:45.489Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cd/5bac242f4e841b9971d5eb94fdfe2577e2b70be983e27401e72055786037/pyarrow-25.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:dda9470024204d7bbf2042b47c6e8a0e47a3eeb8e34405882dfaea6577e0c153", size = 37622415, upload-time = "2026-08-10T12:38:51.107Z" }, + { url = "https://files.pythonhosted.org/packages/63/1f/96d03b4e1506524f7087adb0fd6b2f69f0c9c7aaff1ec36d8030082e15a5/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:44a9120ce5bd81936b8ab9a88076e3fd47c2c6838e0e43630fed83626aca81d9", size = 46813813, upload-time = "2026-08-10T12:38:57.773Z" }, + { url = "https://files.pythonhosted.org/packages/98/d6/33a411115b61dbfc16ad6ad73e71730f6fea654ee3667673bc53ab0e2fe7/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0befcf816e45a1af33ac775a9970b749e4868a230c7372f0ae5e932bee27039f", size = 50104452, upload-time = "2026-08-10T12:39:04.579Z" }, + { url = "https://files.pythonhosted.org/packages/33/ae/b1b97c9ca87f9f9ddbb5230c798df94eccce61bd79b9b45458c69a478588/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f89685964f46e4216103c75483aac0c0692a5f72212d7ca835adba5ede56ce3", size = 49951343, upload-time = "2026-08-10T12:39:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/98/9e/a112df5cfd5a68cb1d9fc31cfe38c28d5aec9f10865ce37ecef2e4450873/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6943e2fe7954d29d84de45d29d34c8dc36ce96570e67d89aa9976e650a4a9138", size = 53144784, upload-time = "2026-08-10T12:39:20.503Z" }, + { url = "https://files.pythonhosted.org/packages/31/24/97e8bd98f1e3b07e2ba08bcdff690674fbe16d69a7d2712cc3884665e615/pyarrow-25.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:31e49a7888fcdf3a835da33ae777f6bb9a866334e5a789282fc26dcf426f7f15", size = 27870159, upload-time = "2026-08-10T12:39:26.161Z" }, + { url = "https://files.pythonhosted.org/packages/36/4c/b525824ad3094076919273cd97db61fb3d78252dee76fa3b8dc8f76774aa/pyarrow-25.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bf0b672390cdcb640d7288f96b826d71ff4e9abb254a86c89890baf51a29cee6", size = 35885255, upload-time = "2026-08-10T12:39:32.366Z" }, + { url = "https://files.pythonhosted.org/packages/08/62/448bb0e940de41aec31d1a956e63ad9c54afdf122a103cc3ab20c2a3ce33/pyarrow-25.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:38a9a4b4b9613380e200641891495a56c3d5a98a092db4a870af9975e220471d", size = 37644461, upload-time = "2026-08-10T12:39:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9a/13587e38bd4806fd218f50fd13b8903fab60588a699ff0c406372e5b4043/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b726ad7e7b669be982b0c71c07fe4b037d654354130da79a7902a669e93a66b", size = 46877146, upload-time = "2026-08-10T12:39:43.722Z" }, + { url = "https://files.pythonhosted.org/packages/8d/61/1c5d1229fa21da4cff5365e41e57177aaac57c563c727f35419b8513d1c1/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:9171748cdf796972d85a4b60157c279913e242992e350c90c7450182a9838b2a", size = 50131616, upload-time = "2026-08-10T12:39:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/43/20/291e1d65cc0b09aa19f03cf25cf51a2f5fa94b5db315178f2d254ed5cad4/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b7a296aac7a71fa0886c08e155ddb6c636a50013f801f6178daafa0f9e726188", size = 50008879, upload-time = "2026-08-10T12:39:56.891Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7c/1b7c9ec28e76576337e4f97b31141c9a181b89b6d1d6221e9d8205621a58/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0fe7c8b6c03969b49c8c66182e4a18e3819ab92d07cfab5d8370c531b9369ef0", size = 53170864, upload-time = "2026-08-10T12:40:04.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/75/f3d789dc06011a765d14d86bda799cf72ac1d715b6a6edecaa0d73d95062/pyarrow-25.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:f729cfdbd36fd99d543b67a914d2de044c84ebe45be8b34902b299b608c15c8f", size = 28620729, upload-time = "2026-08-10T12:40:51.41Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/647a8ee6f7c2662feb6921315617bc04dcd6034763fb61b1199720bf6162/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:59a2de54c0cbd954da861eee4d1d330f8e909c45b53455baef696380f2c55033", size = 36130288, upload-time = "2026-08-10T12:40:11.014Z" }, + { url = "https://files.pythonhosted.org/packages/93/f8/c9ee997554d7bea94520667dd1933f109ac1da3ee3556d2b49381e023484/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:35935cd5de130aa5cf4dea052a63e6bf2e17006c35c3a468194242b9b2bf5956", size = 37762187, upload-time = "2026-08-10T12:40:16.592Z" }, + { url = "https://files.pythonhosted.org/packages/a2/08/a28c01c7fe9e96e8233ce2d13df1d402f4f999f848f51d2daacd6bb4c036/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f3831aaa25c67a99f99dc8b05873cb9d64560390372e2aa197ce9dd4a3f06a44", size = 46888003, upload-time = "2026-08-10T12:40:23.242Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b9/58612e977d28dc58c878448866838369ee8da2f1e7cc8ed2c84b952aafee/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6a1fdfc6659b6b19022f2e50627fb5cf7156a66c46bf4299379955cbe742382a", size = 50079036, upload-time = "2026-08-10T12:40:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/72/13/66e1402dcc860e1dc2760b1e0292c9a569b62b3bccab69def1b3e907d006/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:169d3429d5be7c752125890620f75a60776d38b0035eddae939651640822332e", size = 50040226, upload-time = "2026-08-10T12:40:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/78/10/3f1a5497a7ef732ab0f03ecca3e66d89d9c0f57fdc61b4794c456b781f01/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:119297a6dc197e45d9c6d4415f7814a67ffa36c180d26f68c154c58067ae782d", size = 53149035, upload-time = "2026-08-10T12:40:41.454Z" }, + { url = "https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size = 28753071, upload-time = "2026-08-10T12:40:46.623Z" }, +] + +[[package]] +name = "pybase64" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/65/c513eab7211590250f729a06aacc0bc95eaf760b9235666e933d200105d0/pybase64-1.5.0.tar.gz", hash = "sha256:545ab2a433769e3b8e1ce2b4f7b07218bbde202f4954fbfe52948b2522120727", size = 149492, upload-time = "2026-08-08T15:42:00.205Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/41/575bcf8d2f67e32cfde5bec8356e4d42c5d2d67452d47d529ee4a2abd86c/pybase64-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30b0bc5add7b5ffbf9e8f84ad8cbbeeac420da70666d32bedecdbf2051e15592", size = 46808, upload-time = "2026-08-08T15:37:33.289Z" }, + { url = "https://files.pythonhosted.org/packages/58/ea/1b16acb03a19a898e7d96f81de4205c65e73b99b8c9f7237849e62ec0f00/pybase64-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:43885294c9e7c79c4a43c42fe759a82e92d8822fe3e7f2f8b23af90e5dbc4269", size = 40358, upload-time = "2026-08-08T15:37:34.779Z" }, + { url = "https://files.pythonhosted.org/packages/5e/80/3f0562eb2e8a84bbd895cf20b0fe7825f3ae6a37c11f281901cb5c739157/pybase64-1.5.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:32db63c2b2ebbd1152538e0c405bcb38bbaed1adba0efea04bd3d4b33e9cec70", size = 88169, upload-time = "2026-08-08T15:37:35.831Z" }, + { url = "https://files.pythonhosted.org/packages/1e/45/4cd44eacb152901c728eac25be8edaf261f2be7b07be690523fe9dc1b7c9/pybase64-1.5.0-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dd4abc5f83ea43fe977caa7111af763e0f2ad5f4143a55abaef8bc4efe4fe30c", size = 91528, upload-time = "2026-08-08T15:37:36.9Z" }, + { url = "https://files.pythonhosted.org/packages/dc/51/247e670ed36906dd0b7212b5bbe896b5d0f6d79061fdfb1118680c4816a8/pybase64-1.5.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eadf5e5fa8c0e2f15a3fe6f5513882f33b4a1b77d8c8cc9252c1e0dcc9e5bf6a", size = 81335, upload-time = "2026-08-08T15:37:37.996Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/a190bb18050f641a3b7d20a382c6f2f0b6d0747fc19220b9294a981af6bb/pybase64-1.5.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:305ae0210e974f5d0dad3f0052559a83297433412e6ba0f8a6aed93bb4083ddb", size = 77290, upload-time = "2026-08-08T15:37:39.11Z" }, + { url = "https://files.pythonhosted.org/packages/42/ef/843ef314651c98e62d1a734fa444f462488e7663d6423d582aeafa4e452c/pybase64-1.5.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:282bd86c49ddd905bc9b8f166433b4e2e07f6130a273a5ca61c55f44005a263b", size = 79090, upload-time = "2026-08-08T15:37:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/36/58/fb24daf5a2d7e762528f60d992c7a37ec50e68945a003443af5a7808d531/pybase64-1.5.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:f091c932bef000b8dff3ee00dfd8769e138021770d46d577168d802af7abd22b", size = 78107, upload-time = "2026-08-08T15:37:41.292Z" }, + { url = "https://files.pythonhosted.org/packages/cc/7b/aa7ebbcd0f9b4d50c8e83aaa1388d7e94fd63d84d35720b67ea865d4a87a/pybase64-1.5.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c7010b9ce91aaea5e389a7c4de0b8459a5a05a6795921124d8c82928eb13c4a9", size = 76113, upload-time = "2026-08-08T15:37:42.298Z" }, + { url = "https://files.pythonhosted.org/packages/27/48/f7d8387969113a0b590de921fb5ab4d7e9d554a783477f15a7ab4056bf31/pybase64-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7ec51301e1f9f1fbdbd3bb6b34e0df08f5272937e0f86f535e9616341eb452af", size = 79293, upload-time = "2026-08-08T15:37:43.517Z" }, + { url = "https://files.pythonhosted.org/packages/3e/04/88e7c5043bbf459123b1cae785c7f0bd15fc3c524ca5e6df47b3170dc06d/pybase64-1.5.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6ab1a34d824efc0bf235c0abf9415256bbd74288cdfc47f6646ec9fce04076f9", size = 72740, upload-time = "2026-08-08T15:37:44.614Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d9/ee6825788704f64f37a4f8215f109985fb46b38f30e3791e2403f42997e2/pybase64-1.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0234b8f85c8816e82bbabf67a37014c3aaa2a668d3ab92fb5ef52c511318c84a", size = 87996, upload-time = "2026-08-08T15:37:45.843Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c2/33026e61bf216a2e9f65a0ab7de4ec504c6fbdac0d6597cc5077838ac9f0/pybase64-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a80226a2135de8a454e6812dd604d1c42c4e94269223b242395d689bf247824f", size = 76536, upload-time = "2026-08-08T15:37:46.92Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2d/03d706e2a1afa19e79d0b86170eedaf921e4889fe69e78c005892ccad3bf/pybase64-1.5.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:aea6ab63971f72f69b2cace481e0df9cb01486317296e7809a086a71864a6013", size = 75598, upload-time = "2026-08-08T15:37:48.219Z" }, + { url = "https://files.pythonhosted.org/packages/19/4e/a29aab4757a46ed957306ed76004db8315de6da09535bbb4a34c213d9c1d/pybase64-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:c3455b23f785486a5ab3d2b8bfc7f573d1bab0a10d061fb9b7f596096e316ae2", size = 75558, upload-time = "2026-08-08T15:37:49.466Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3c/92bd29e54206d300eec94f7b634b48f1de97e0ffc0cb70c481ec2751a586/pybase64-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dc5b02c33ee9dee2cb3487c5d381bf931ff22144b1711fa093727fba991347ea", size = 90406, upload-time = "2026-08-08T15:37:50.58Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ff/ee054f2750b35727afd85a41a6d5e84be2130468d2ad10f7e0deacf64683/pybase64-1.5.0-cp310-cp310-win32.whl", hash = "sha256:352860c3c88a6ff74ed877755e20084e7645cbd5ed973448ca38f83c0aebc2ec", size = 42370, upload-time = "2026-08-08T15:37:51.721Z" }, + { url = "https://files.pythonhosted.org/packages/35/bc/ace0ad65a17acd0ecb762f5e1fb5c4a99a0bb02a2866ffb591e50b2ede05/pybase64-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:283d2fabf23e356e72b4fb8a59f5e319202c0328c748f6596f14459b0650bfbb", size = 44484, upload-time = "2026-08-08T15:37:52.766Z" }, + { url = "https://files.pythonhosted.org/packages/86/aa/3b21a1a11da0dc0e2e97199da211eb9939fce51fe6b98680777ab53fb897/pybase64-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:8e6afda6996523b29d42b8b9dba309d2bad53fd2eaa06189d735c8c7e2885455", size = 39906, upload-time = "2026-08-08T15:37:53.811Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/97242944a88812139762723196e27a24a1484535c7e614c808f513b6dbc5/pybase64-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:43df778a20db59f231b02c6dd70958e1fad532fc8a4f6bebb0555e74abe01898", size = 46805, upload-time = "2026-08-08T15:37:54.941Z" }, + { url = "https://files.pythonhosted.org/packages/0d/30/c0471ba2ef6e15f153b8dd629c66f919f3f770c372c703a5637503282097/pybase64-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2615d10e4cad323925d2f7d904ae38c6ae439b33069a0d56cc4ce64ea4c9b339", size = 40351, upload-time = "2026-08-08T15:37:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/29/f8/96c1413310f696ecd3364d887073f73708121469ff7f3bd3e24ec8dece6d/pybase64-1.5.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:045fa2f3f5da6cfa86822c645b92e18cfc7c13babccb5ceec9bb64a17ac3f1bc", size = 90662, upload-time = "2026-08-08T15:37:57.091Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ee/591f24aef04e1ca569450b85c86df0f8a8b3dda04f68cdbf6101312456d7/pybase64-1.5.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93bc9bdfaf87dc7d79ee0182b255383b7f82a3167d0166b99330d897b59f9053", size = 94201, upload-time = "2026-08-08T15:37:58.696Z" }, + { url = "https://files.pythonhosted.org/packages/20/c0/66a721f8d0d5f3d43704b78b30ddc51d07eef24ddf94470c8e1808b0826f/pybase64-1.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b08e4a065c9fa88ab9b8a2345b58073776806488b1ff5e4348957d0aa218043", size = 83843, upload-time = "2026-08-08T15:37:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/44aa61b4738b9827e50fc081c9072d553fc538a372580a6326771848d1cd/pybase64-1.5.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:897ca382ec6c7bad041ce7b3a64b3a15f1b639dfea89ffcf29bdd235c706fac3", size = 79555, upload-time = "2026-08-08T15:38:00.942Z" }, + { url = "https://files.pythonhosted.org/packages/98/f2/db862e347968eeecf96729244f092a8e1d9bbc1daf94aef4baf3446296d5/pybase64-1.5.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3398eb35a82a94d61756f7a4ad6a1c5a3e735c6abb97167398a22389a9b8ca7a", size = 81765, upload-time = "2026-08-08T15:38:02.206Z" }, + { url = "https://files.pythonhosted.org/packages/86/37/2ec5e90db7c1d01c126b02933adb31838ed8f4d8834193c4f2c1440e2c28/pybase64-1.5.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c3935b4402f257d9c7448944db07f91d6fc20453f8c3f0fa1bf26c490b534c84", size = 80619, upload-time = "2026-08-08T15:38:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/9d/80/09093682d7834a0cf8516b4d9b2b9ba579abb54c56046666ab12f97208d7/pybase64-1.5.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a167f17421c237a32c93072a053ff756d9fb225e69a620c3f4818665f0520044", size = 78240, upload-time = "2026-08-08T15:38:04.484Z" }, + { url = "https://files.pythonhosted.org/packages/20/6c/57d95e6ff206d7aabb0a6ec54a8d860ca1e0c84fee611eacfe95945b7478/pybase64-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:716aed288780c9c2081943a3a7b5be6993cdad56b0cdcb4ef4b562ef56c5a1ae", size = 81888, upload-time = "2026-08-08T15:38:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/d0559d16467c42ac91501e5e423d65be1801dd5ebf40f4a244134c760a46/pybase64-1.5.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d373b682dd0a267ece21869ca9a48d40b55120a3be714661ad0e9afdce9ce27e", size = 75233, upload-time = "2026-08-08T15:38:06.645Z" }, + { url = "https://files.pythonhosted.org/packages/40/2e/4dfee2d5c37473cb91204dac4f1df83710da30fcd93ae345c2a5acb6bdb9/pybase64-1.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5d02948944dad3e99ebe70a3049d7df66f5faba97ed03b411349b034558ed936", size = 90524, upload-time = "2026-08-08T15:38:07.791Z" }, + { url = "https://files.pythonhosted.org/packages/8d/07/e2595a9b32d2e635514de93847e1dca2bd6361c34b2e142b46b0eac852f0/pybase64-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d83f517403ff39404b8586d07e97c019cb2a7cb6665cb070c6aebf1fc03e5487", size = 79217, upload-time = "2026-08-08T15:38:09.004Z" }, + { url = "https://files.pythonhosted.org/packages/13/ce/57bc5269db8cd07f427e7b42149f00194106c80d02f4147411005dee7522/pybase64-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:216b78caa73ae9b82f3f006e9694ee5a1bde89e50f4552fd1679b56b080cfb7e", size = 77807, upload-time = "2026-08-08T15:38:10.195Z" }, + { url = "https://files.pythonhosted.org/packages/bd/08/f366686f58858af2ec5dcedb80c394bf264927ba12c99d9a7233cca16c66/pybase64-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0855f67fa47c0bdf237ea875c11afce2a8cd879644b288d3f05ed9effab17953", size = 78181, upload-time = "2026-08-08T15:38:11.321Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fe/de44b16a234b12adf2a2b26a758805de8e7a88e2ccd547053d3dd96d8c57/pybase64-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a707d36935229ae5c3044cd601908cb7bd9f25757003d029765ccf66818301ce", size = 92950, upload-time = "2026-08-08T15:38:12.464Z" }, + { url = "https://files.pythonhosted.org/packages/25/42/b2b1ed374bf93defab1d3482713ff76a9e56b61606eca5853c10a8ff5bf4/pybase64-1.5.0-cp311-cp311-win32.whl", hash = "sha256:e868946a538178990a43fa6bbeff1eb027e515d6269743e4d31d19f72daf00ac", size = 42377, upload-time = "2026-08-08T15:38:13.598Z" }, + { url = "https://files.pythonhosted.org/packages/11/9e/3e0be8871e71f05fe98074f40bd5749c17567e01b2c998b1fd88530eba38/pybase64-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:49c62921f55d9d7713faeb855bd9aad1edfb8e09e2c8133b7058d4c447bdaa6e", size = 44491, upload-time = "2026-08-08T15:38:14.646Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f8/114f6da0f71003a6826fe30a24096e98eeba6c506b1f9cebb2689b01305a/pybase64-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:8dfe4566d653684daa21f41c75c8a64a8333b36a4377ccb12a1f16e321d7d1ca", size = 39913, upload-time = "2026-08-08T15:38:15.744Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/dba60f937caf26a6e2be6a138f5422da9f4ec988db49bd4e329bcb435cd2/pybase64-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9732eba18ba7fe44c1b2827bfaadf381fed3789bd7e20c990e6c8d1ceba0179b", size = 47155, upload-time = "2026-08-08T15:38:16.705Z" }, + { url = "https://files.pythonhosted.org/packages/b6/61/302d65a981c9baf156e4becbbbe49f38de72906c430ab373d6d1ca0d4258/pybase64-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d1149b7360dd99ef1ad10618df2a4f54a00385bc8d2c1aa244c0301a548ac415", size = 40490, upload-time = "2026-08-08T15:38:17.95Z" }, + { url = "https://files.pythonhosted.org/packages/1d/66/9f1be6a4db86577eebf3106496a2a791b37e5fb74695d4c8eeedbd04490a/pybase64-1.5.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:80b171f1546935be4dae1e01bfd8630d2712271e067858b7135726e7d9bc7cce", size = 91058, upload-time = "2026-08-08T15:38:18.983Z" }, + { url = "https://files.pythonhosted.org/packages/af/36/4e44a0688efe26434bf378b4565b01ac94f81422e8a5746291a03472cd56/pybase64-1.5.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1a2b9cf39b4d30f600df8c56cccbc03adfc6e1ae8c04cd6b181105a432d4a515", size = 94681, upload-time = "2026-08-08T15:38:20.59Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d1/fc02005906fd48081b7b8f077cd422a55399fa351c2a6d3e5fed951794ce/pybase64-1.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:865b7db127a95e33640ebcdb4bb3aad165d4873ee7c1008949129f3c4f900dd8", size = 84634, upload-time = "2026-08-08T15:38:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c6/5bb0f21a9f4d231339a42f16ebabc7c6d9a7d619e756327b15a474650ece/pybase64-1.5.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:3344ce336d9d8292125369c1475d1663e7e1a06894e8e5150307e11f782c6afd", size = 80455, upload-time = "2026-08-08T15:38:23.05Z" }, + { url = "https://files.pythonhosted.org/packages/b8/04/0ba9a1f2ea39baf081dd44d22d710d9b050ce15991d641982f1814508484/pybase64-1.5.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1aaae81669bf18b5a35dcb43dbb200f52b13f847a56bed7a2e82f31cc6f9f74d", size = 82304, upload-time = "2026-08-08T15:38:24.156Z" }, + { url = "https://files.pythonhosted.org/packages/c1/9e/6b380ff964dd77b79cc1ce565b73780345132e0e181d315f31a2263c5e1f/pybase64-1.5.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fb5dc922ce3cb4211caa7e29e6daee98f319e59f297a904acd74f2fdd0674356", size = 81259, upload-time = "2026-08-08T15:38:25.327Z" }, + { url = "https://files.pythonhosted.org/packages/b9/93/dd7fd7f8ed228f7735ec59a9f85f3c683cef371a76b29520344655bf7c97/pybase64-1.5.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:356e7bd1453551c06231df8411bfbaed9998fbcba2da723d84fb270ff1f977a7", size = 78360, upload-time = "2026-08-08T15:38:26.678Z" }, + { url = "https://files.pythonhosted.org/packages/d8/99/b5e9e7d4b5e49d7a984c4a26b48bdf988ec62c2778df80144af1a39bd4b1/pybase64-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:11dfa286f6c5fe6795430bf08fc44b64c98e208558215b0590c9f28fd99a92e3", size = 82358, upload-time = "2026-08-08T15:38:27.856Z" }, + { url = "https://files.pythonhosted.org/packages/67/fa/19d11ee70fbdb10e574a39ad7fc7adc06e5635a2b2ac291a6554c7c651ae/pybase64-1.5.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6be40c3311eabe8a816e00041844f9b249828015dc98be8a48a7c3275954ee76", size = 76384, upload-time = "2026-08-08T15:38:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/71/32/a83622dfa3162dd6fcd019dd8fbb766f0ce064fe67b3d3d2759881dbac4e/pybase64-1.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4e8b163c8d2d2a5f414f2c31cdd91024e0c91c72e735a9a564a62460ac838acb", size = 91407, upload-time = "2026-08-08T15:38:30.306Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b5/1707748813784af0b1340f6c6525887f1ecb393c3f88070a2bb2d86bd94e/pybase64-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0030a64fe91791e5e553edaff3a55d319cd07fb5e097b09c5f7f45e4905c40cb", size = 79687, upload-time = "2026-08-08T15:38:31.771Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ee/8101e43b5cc070c0adf298f87500154c13b9097d4456a2c1aadd71339329/pybase64-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:28d5db510433bb1544dc128c4e7ebd85ae57cec2a4608edd1f7ca4fed3e53b3d", size = 77913, upload-time = "2026-08-08T15:38:32.898Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/43b2281077ca9a531bd896b7a9fe871d091d80d172d68e439c7aa6337033/pybase64-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:26422429a0bb2f15773dacc0fcb1bcddfce68c6b2d41fc14bc7fc17f8c529542", size = 79172, upload-time = "2026-08-08T15:38:33.974Z" }, + { url = "https://files.pythonhosted.org/packages/c2/1a/b536e571518eb2f4a2db1c6c7c5913af5780ff82c9eefb41f674fed71ceb/pybase64-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ccbae849677648be456ea0de769a78e432d2d24f71cbdc739741e69f8160e0d7", size = 93636, upload-time = "2026-08-08T15:38:35.102Z" }, + { url = "https://files.pythonhosted.org/packages/54/c0/318f79b614fa03089bf4672194325dfa732790546530697b55a53612637b/pybase64-1.5.0-cp312-cp312-win32.whl", hash = "sha256:d691553d1a88ed87cf1837babec3663275b29de906b48433c15b298e262e5243", size = 42443, upload-time = "2026-08-08T15:38:36.217Z" }, + { url = "https://files.pythonhosted.org/packages/e0/80/eecc05ebac8d08a2bf855cc7bbe6a37d8c76cd19c6337c9b9fbe3225ee19/pybase64-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:125945f5b3cde8b79a8f942cfdb0390f4388fb9458a41f5f2a93746e1ef3c546", size = 44565, upload-time = "2026-08-08T15:38:37.734Z" }, + { url = "https://files.pythonhosted.org/packages/b4/87/193dbb1eaf7751527a7e0510f5670efeed8642ec647b4c7177c384a6f7e9/pybase64-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:c8b5f52776f0277e72a9c7e7944f682de2b3ee4655b7972a48c53f871963741a", size = 39918, upload-time = "2026-08-08T15:38:38.808Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7c/b359e979a2b53f1aa9d8f2d9f90b29eda90d7dd126c2871dc49db4d6d8cf/pybase64-1.5.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:2e79853f8e52ab0afa7b3ae445de23767b033fa0e58ad11099d3c6b79d012c7d", size = 44413, upload-time = "2026-08-08T15:38:39.883Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9a/7412bd0e2c011069c754a1ac3e05ded9eab56614eea6d9251c74a434a472/pybase64-1.5.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:7661246f93c902bf147d5f7d72874902ef3e49a63ca3f0de333cb8e85765d2fd", size = 49859, upload-time = "2026-08-08T15:38:41.048Z" }, + { url = "https://files.pythonhosted.org/packages/a5/17/a1fc8e55551530876d3be31079b8701b7f5ac8451b63a08a19a4f9714454/pybase64-1.5.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:75d21d0a2cae0bb071c68686d77e5100be611ec4e80e0d97f8736c27da0ab197", size = 39681, upload-time = "2026-08-08T15:38:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2c/b46f7e0c1ea482db0f8445d5bfad7e5a4f39d977868e10b4c3823e94fa20/pybase64-1.5.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1bde27266ec4a56c38ef8e17998e430d30cc6310fde76332381bf5aaa81872ba", size = 40200, upload-time = "2026-08-08T15:38:43.354Z" }, + { url = "https://files.pythonhosted.org/packages/da/12/085dc70e757e6101c8f61239bae538640aac60ddfebb41e2534af3712e14/pybase64-1.5.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:220d8ab003d44144d80f8b776019adedc23fdc7bcb270396744b9805a8186d0e", size = 46726, upload-time = "2026-08-08T15:38:44.378Z" }, + { url = "https://files.pythonhosted.org/packages/60/7b/f3213973e61b8a8d1bb78203fe226e7f368698fb931249eacc09048d2141/pybase64-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d42196f594460a083084d8e3c2f2554c958ebd8fe19bc30ef1b938197436e7d5", size = 47242, upload-time = "2026-08-08T15:38:45.478Z" }, + { url = "https://files.pythonhosted.org/packages/1b/64/e847e8710261596b3e7cf0935041a1c96a50fb2a7f3e9e09bc495510b25a/pybase64-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa56c549af248664ed7e1cc8ebc4dd7f1505b1444d8f3bf15b6a89b43dd4151f", size = 40628, upload-time = "2026-08-08T15:38:46.597Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c3/8171fd18a57218c5e7c252f658709f9bd3d0eece9d4196542230103a53d6/pybase64-1.5.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:a1529b8e08a93dd9c00d1e3b3c2b627a9600d96c2f40143dc0b3a85f48fa85e5", size = 92183, upload-time = "2026-08-08T15:38:48.038Z" }, + { url = "https://files.pythonhosted.org/packages/23/84/b91aabd22a65a3679633855dde720dfb86571e15f88a9b1b295adda90e8c/pybase64-1.5.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0be37689b624ae293394fc826c9a048c6118520d6a962de033ffb054564bf61f", size = 95718, upload-time = "2026-08-08T15:38:49.104Z" }, + { url = "https://files.pythonhosted.org/packages/67/cd/441fd3b9bc7a49846362fb52a0971cee6da4dca2eb8545100ec043b2a0da/pybase64-1.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bf98b77c6cca5c5da30135b69b30668da07a32d41210c62121b34c84239d9d4a", size = 86068, upload-time = "2026-08-08T15:38:50.683Z" }, + { url = "https://files.pythonhosted.org/packages/2f/24/48cfe7e1b776c0af1ce5240f7e71383890cd361242e537b6c510804a68d2/pybase64-1.5.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:0578c54f1ae89e6175eddb742dbaf2e95a060735ec11f4b661f762b635680cbd", size = 81077, upload-time = "2026-08-08T15:38:51.825Z" }, + { url = "https://files.pythonhosted.org/packages/db/38/5b47895e2f19f9775a3daaec98a652ba7c0ccfb480c223d981c2ec75c0ed/pybase64-1.5.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ae78cdaec57f21e7f44cc5f9866d694cc072e1b1082286f30fd74e7545fa2916", size = 83387, upload-time = "2026-08-08T15:38:52.921Z" }, + { url = "https://files.pythonhosted.org/packages/74/2d/115526e63080e96ce039619a1a29a4fe49d138c5d7d525b6adbccf0c1c0f/pybase64-1.5.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1f315f07b269f074995c445b65dfde62d12c0e889e9c3b0534befdb05866e880", size = 82460, upload-time = "2026-08-08T15:38:54.436Z" }, + { url = "https://files.pythonhosted.org/packages/53/b8/8970ecca7a5945f81d34f9a91d23169f7e62e2487ef3694e0004943e7243/pybase64-1.5.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:99570e43605b9c849ff1606e1691e503962250f80ec3e827249f7ad820e402d8", size = 79359, upload-time = "2026-08-08T15:38:55.69Z" }, + { url = "https://files.pythonhosted.org/packages/55/06/eea9cb5955430d5f789c18eab854284c66b1a024efae4928992d44bcde65/pybase64-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e0143b3515b97bb3c4743fbdf10f53950c0bb1fe1a2db1054b422ba370594333", size = 83768, upload-time = "2026-08-08T15:38:56.793Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/a121c58260d63d16861fd936373d07c4ab0cef51b0d7391cafaf8e4648c0/pybase64-1.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b0597ca31c472f3071844648ce5ab86a1732033ca230daffd8f87c6f8596a8ae", size = 77416, upload-time = "2026-08-08T15:38:57.995Z" }, + { url = "https://files.pythonhosted.org/packages/24/6a/ea3a1078de626ce765402d6d3e1cb6d69f83104646bcf2e2772983be77aa/pybase64-1.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8d303baddeddaccada149bbee270b3e2eedcaec2df082834895cdd897a602674", size = 92473, upload-time = "2026-08-08T15:38:59.149Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/22878279f1663bea15b5211056e3c8cb19c4783d2566a0032bcfa37d678b/pybase64-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a34261348f88443d9e234f251a1f1fcb711c1cc006824fdb29b649735d8ac35f", size = 80804, upload-time = "2026-08-08T15:39:00.271Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f0/57c36867282341ccc47c0db67590dd8f0c621fd435aa5944bec4713138b5/pybase64-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e675b15b7a7b81e5b1a1e747cc49f9f9e6649d3b5e8a61719b46b9a671433210", size = 78871, upload-time = "2026-08-08T15:39:01.429Z" }, + { url = "https://files.pythonhosted.org/packages/4d/ce/23b80fde747156f6387a2f769fac1384e2e34cd4f07daa32e990991eb64a/pybase64-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a1f8f1bb4158069291fe6ac2d34db942418f2804564d04b8e97722041035f843", size = 80451, upload-time = "2026-08-08T15:39:02.764Z" }, + { url = "https://files.pythonhosted.org/packages/bf/02/1486ad47fc065bbaa45c12229673bb03f0480dabdba408b04a54ac480264/pybase64-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0abc0f2312c17765bf92dd382982cca9dc1b0148bf0d708f5f88339d84bb7687", size = 94725, upload-time = "2026-08-08T15:39:03.877Z" }, + { url = "https://files.pythonhosted.org/packages/43/ec/bf6a0df18b4a627a2ad3c8897e67797cb8128fed8cda2b654dd9ddebba25/pybase64-1.5.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:92998479a2a4464d141ef709e52dc3e4d4d4ce7f3b9cb5052d2c56c55b405b15", size = 33074, upload-time = "2026-08-08T15:39:04.939Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4b/58a70d9655842161bcc3ae73efede60ad83d6d195fdf110f0c0ed808bca0/pybase64-1.5.0-cp313-cp313-win32.whl", hash = "sha256:91aceea4287299ee60c1176909efd6f2de091da24c0d93d2f9861c93e3776ef7", size = 42557, upload-time = "2026-08-08T15:39:05.992Z" }, + { url = "https://files.pythonhosted.org/packages/ba/43/157fddaa16e53e50813dc73b2cb9e4d03e797427394657e89e14a1a8843f/pybase64-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:d01e4d495c5b10e79de3449501e41d2bc2a4aa90844a3735eb962a3a01645971", size = 44628, upload-time = "2026-08-08T15:39:07.067Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c5/b5814d726d05749e6d5343a61c270a3c14a1f41faa20f4044ceb4f96d87c/pybase64-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:1f7ddf3a7f1c85061f246a481c63a70d7aadd0a49add8e6c109b65360fbf923e", size = 39953, upload-time = "2026-08-08T15:39:08.188Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4f/111bc52f03b44d569af3988a0665b2747ffe0e2a94008d03c976966e962d/pybase64-1.5.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774fe1a69e99c60ef7f5fb3d688e85db707e232355b4c93bbb96b4d17c5503cc", size = 44417, upload-time = "2026-08-08T15:39:09.252Z" }, + { url = "https://files.pythonhosted.org/packages/d1/0c/432c08ff8dad0b08035d1aaa85afedce263d321cccddd6d63282aa736800/pybase64-1.5.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:b813d6eda1805d7d8acb176589ee1a51c4d0e5e3245093eddbd330d6508bf112", size = 49866, upload-time = "2026-08-08T15:39:10.78Z" }, + { url = "https://files.pythonhosted.org/packages/92/41/cef45112b1c853c58a5a47dc4fb823d1cd7c79cf24bb8424ef7fd3fbb180/pybase64-1.5.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2b5563aca0b7b74751dafe6cc3e1850a3401414c05342f1bbeb26549b5c3bda0", size = 39687, upload-time = "2026-08-08T15:39:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fb/9058281862be3a2a12b1b2bd48addf8e0eaa085c1cf75e22d49663b22a9a/pybase64-1.5.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b6cb9e548816e0838b10d29b061cfbbfc81b726f6c5f89d60e83bd7d703ed06b", size = 40208, upload-time = "2026-08-08T15:39:13.588Z" }, + { url = "https://files.pythonhosted.org/packages/60/f0/f6ff0e564d4d2f4ac9161d6a8445cbfb317c83ad9f79deca3c3bf27b8b79/pybase64-1.5.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:435064ff2fc778a02d1234289a22050a4d3b29752062b5ecaf45eae62273ec47", size = 46726, upload-time = "2026-08-08T15:39:14.689Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1e/fdf9e6e71090a1e31fb4967ab3042301ab71ceaa800f3b1805be29e4dfd4/pybase64-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d7c77f38e6d0b5bf8d7af9cb9c6bb9f4e62f25edc2931251d46c3ed0d89121ab", size = 47350, upload-time = "2026-08-08T15:39:15.798Z" }, + { url = "https://files.pythonhosted.org/packages/22/39/69828d263af0d31c8ca99d7cae4cf8a5a9f37a1bfc63f2a40afb9cd2a805/pybase64-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c3930278a6635dac4dff15f8f336ae643101608160f4525e67a9fc8416061daf", size = 40647, upload-time = "2026-08-08T15:39:17.019Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/cce345652b019e2a80e51b8d31bd4fa1662612ff1260dfedbcd5e1675106/pybase64-1.5.0-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:fb1734c69974acaee369726b48031c0d0117830bc050188086a69227c32d2426", size = 92385, upload-time = "2026-08-08T15:39:18.169Z" }, + { url = "https://files.pythonhosted.org/packages/86/0f/c332c26d75b0f2bcab549fe746b6978b2928d8b94fe226333c7e94ecfdd1/pybase64-1.5.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b391e54bc8198387cf089ffd343d8c99d58e73f209c31aa2e5f420bf20bbb0c7", size = 95720, upload-time = "2026-08-08T15:39:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e5/0476f7b28544d29225bc1b0be5fd613ab62c38080c65d55299a8f1e7e334/pybase64-1.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1626f1de1d7c109e25e20528cf1ffe17d0b614baa87c9d20f6181cb65234168", size = 86226, upload-time = "2026-08-08T15:39:20.473Z" }, + { url = "https://files.pythonhosted.org/packages/75/5d/5664794aff60d8df94371a466171940c3ecb081d76d24ca1327dd32aed60/pybase64-1.5.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:ade98a94cd71692baf0ab21245ecf9a2f1c275460dc4106e23ce9aca1c4c1838", size = 81080, upload-time = "2026-08-08T15:39:21.899Z" }, + { url = "https://files.pythonhosted.org/packages/f3/32/6ce14f3209f1629e11b11f1c44f545b87ebe88a2f35e469526d72f2fe0db/pybase64-1.5.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:cbc41c5376b30ba7b3d558505f7598799034c8aef30e3cee00f32bf8d26fbede", size = 83557, upload-time = "2026-08-08T15:39:23.429Z" }, + { url = "https://files.pythonhosted.org/packages/38/5e/0d73f7f9d3e4579df08af94847e39a675b654b4c99330ef1b5718594406c/pybase64-1.5.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:be98a4e72e3821714770ed290e5e1a8a6cabe77af58520a9adf718acc43a165e", size = 82370, upload-time = "2026-08-08T15:39:24.696Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f4/0831d2736370a4afc690aabfe60e295e2773456efdc764513974f7b2b2d9/pybase64-1.5.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a8bc9cb80cd736785aa39be5e5d934772a36f9ba30fa71b7c19dbe1da44a306f", size = 79538, upload-time = "2026-08-08T15:39:25.859Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/c897af35bdc3f4e26d1050c8ada1eb91dff87e601681bb6b8a3f47db6b42/pybase64-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aabdeccdd1be80735cd8cb815565d9528c767113358fac2e8eba21030e018a65", size = 83889, upload-time = "2026-08-08T15:39:27.024Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/89be4c77ebbba058ea1d263a62349d306b13d92626b2593c4b56e01321d2/pybase64-1.5.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9d16bd1cdbb63985cb2f3ec4bda4de13ba6396c1f81468941c650b4157670ee1", size = 77092, upload-time = "2026-08-08T15:39:28.573Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fe/eb0723048975618b73f3dc4b3b4e906b17aacd50916f3de3350a9980fbfc/pybase64-1.5.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:37daeed30664d0d59dc0c99707a3a3fb723b8dffdf62266078308b9b26c7a18f", size = 92793, upload-time = "2026-08-08T15:39:29.739Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/a6bb0ac9aa970322ea30b37af8054a8110d2422cbe7bcaf99cee110d77db/pybase64-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:40fd8e16bfde1e9d80700bbdb51a830c0f7e384c2130c4a8ed5f0912fb269cce", size = 80873, upload-time = "2026-08-08T15:39:31.017Z" }, + { url = "https://files.pythonhosted.org/packages/6c/00/75c2ccacfc7bd47d50bdc91fee3e09582ca9bf047414fcd44ed9d61e55a5/pybase64-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1c32f2078df7e3c4f7e573592cdcd8eb50c827cd51226291ee867c217f036abe", size = 79031, upload-time = "2026-08-08T15:39:32.269Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ae/f6cdaea0a266cdeb485f6088551b8413361947f008de21ebe1479b5c5042/pybase64-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a119cdd2e59b30aa570e75182b22fa149da50e921ed8b4c492eb9ed308d944c0", size = 80520, upload-time = "2026-08-08T15:39:33.463Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f8/7961ef761d93b1bca865dc84e99ce071f73b05a8f73e2759e19b42732d1f/pybase64-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:82b38c11b73d4ea37b1d76d4690131472ce6a144166a63fedf336d88a101336b", size = 94804, upload-time = "2026-08-08T15:39:34.589Z" }, + { url = "https://files.pythonhosted.org/packages/ce/76/4dcf99fb78ed8cbb5c45d4a4580ed7d3206ddf098f4d9bb03f9f292c3e7e/pybase64-1.5.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:6260074fae5bc47838af0fee1a6f48530d1ac7b5f49c80868144ba2f69f43145", size = 33041, upload-time = "2026-08-08T15:39:35.771Z" }, + { url = "https://files.pythonhosted.org/packages/21/74/e62ccf872d585b6a9a557ee9b722a3e719fb9b478bd6aff8e741d7d59470/pybase64-1.5.0-cp314-cp314-win32.whl", hash = "sha256:1003c3643cb785b90237c9fab9163dbb349b17a774f9421488a2147f7382c134", size = 42559, upload-time = "2026-08-08T15:39:36.827Z" }, + { url = "https://files.pythonhosted.org/packages/45/32/901b374323483332e7a5c4a999a571b853e945e780da0223438b3d4a7220/pybase64-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:4bb9dd97bdab9b6ba0e80f9d83e140e8263567d28878fcc52f8f0f41990926a6", size = 44674, upload-time = "2026-08-08T15:39:38.16Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e2/50c2b5939360b6682742fdc6d12e3a9e37090b1d25206d25131b66e61238/pybase64-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:216a160461168c12c5ec0d6384a0dcb73e7b3c392df3e30c1fa11cff1cc8be82", size = 39982, upload-time = "2026-08-08T15:39:39.629Z" }, + { url = "https://files.pythonhosted.org/packages/ed/9a/8afa00f5daa954c1f9975477648cd1c55c5c7eec2a0b0a963323ca8da286/pybase64-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fbf8e901a9caf045062b7a1a8f7db056c492a5a76a0c612714ed7abb5ad42f7a", size = 47688, upload-time = "2026-08-08T15:39:40.754Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9c/10bb03155f5dd605befde5b8c5f9e867b5f2b885fc3e4afdccbda02c8c0a/pybase64-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f5b5f72a0d761849c75b0524606707b28600eb9bf75263e7f36a7ca33627fbbb", size = 41021, upload-time = "2026-08-08T15:39:41.949Z" }, + { url = "https://files.pythonhosted.org/packages/86/69/54f004e0f5ab8e7a96b1a43198e2fb554c2a94c4b78f553ebfef733377b7/pybase64-1.5.0-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:0872880c9150fc79347c658937507033b8e600570569e4494e1230987e91be04", size = 97305, upload-time = "2026-08-08T15:39:43.093Z" }, + { url = "https://files.pythonhosted.org/packages/a7/cb/d1d33080136e437e49d73bad2f27a1ac3129b058585c71fdab2c8783fa2c/pybase64-1.5.0-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:106dc1813dff9ad1e936ab6de486bc0e19d281741c1cdcb3effe31602c571d71", size = 101290, upload-time = "2026-08-08T15:39:44.318Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a0/b935f321d7f4e6317487b9452492287f4b709465290445bb8daa104d5264/pybase64-1.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c1a3279af228faca3c224cc8c30aa130b5f3184ba420ac477de1db2cb99be8a7", size = 92538, upload-time = "2026-08-08T15:39:45.863Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/5775be12186b4bdadd9b7bceea9871af097547d5a2d8bec4e43ed9d5408e/pybase64-1.5.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:d8e05ac71573089f25cdbad4b01db8d0b8e82846cd42291ef002d265903b1e41", size = 87146, upload-time = "2026-08-08T15:39:47.04Z" }, + { url = "https://files.pythonhosted.org/packages/53/0d/15b1ff749dafa2146a0a7aabff8596cc6bbb4277fb90f56a0beca9cdda92/pybase64-1.5.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:08907ffbf8381a017f6332ce02b818e672c73563ec19f38a022a34fd1c55b493", size = 89500, upload-time = "2026-08-08T15:39:48.198Z" }, + { url = "https://files.pythonhosted.org/packages/8e/dc/bcfc83c650a83f814235c56c810570fddb382a23ad3f79c6816e0c9b4351/pybase64-1.5.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6a5f053f077aa8f0ffe5d4d03dd7d3fae4b85155942228a6dd20b467c4d7d80", size = 88169, upload-time = "2026-08-08T15:39:49.455Z" }, + { url = "https://files.pythonhosted.org/packages/ff/43/992c2aa344020575b0539c388104cb9ef45c80429f99acd0f177d32bcce2/pybase64-1.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1e149af6b5a5af697725abc52aefef7e3ab036f21f5c229848b0f8bc8f26edee", size = 84770, upload-time = "2026-08-08T15:39:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c5/da628bb02323057cfb8653b427fb3b6c363a395e954dff38c512fdfeea56/pybase64-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:678cf90273ee5fa7cedb35334c765ced4dad38608c0258445da009c1da9dd174", size = 90101, upload-time = "2026-08-08T15:39:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0e/5824a07fd5f64e80ea042624f0e2b03cdaa3ce786e201c02746b2552d4ff/pybase64-1.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7dc71ba89766bef4bd2d9be8a827ce784f1c85915b8bcad2deefd7d892d6816e", size = 82843, upload-time = "2026-08-08T15:39:52.892Z" }, + { url = "https://files.pythonhosted.org/packages/b8/21/fb0e4da0de2e5bbd3a9bee14c6919550a6ebdb7344776b7730960a8d37b5/pybase64-1.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c6b6c15473fff013dfcb0b89cfcbc922442459b08e96d37cdcf1a8bec28e4ed4", size = 97635, upload-time = "2026-08-08T15:39:54.095Z" }, + { url = "https://files.pythonhosted.org/packages/06/ce/c382f3401435e04a2440cbc6beb7317278baaf4e2fee28846325446f669e/pybase64-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:831c25fd670727aea65525b9d6cff00718f26ca92433f9ed039fe67af9825388", size = 86714, upload-time = "2026-08-08T15:39:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/a1/90/f0552285b2e17ae405e675debf3fa6b999622b1cc3f72584b9cb3904584c/pybase64-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f2509dc39574f1a0c60eb5f6c968e6f064b55bea88506df25d15ba6d391b1c48", size = 84316, upload-time = "2026-08-08T15:39:57.268Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d8/66b3468adf62bee0c27cf39aa1f2da6d9b2e79f01d25c7af0310997502b4/pybase64-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:fe57aab650c771802cc7b0eb541a74b6a181cd1870f61c537294ab462fec34e8", size = 85344, upload-time = "2026-08-08T15:39:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f4/8fc0795eeaaed8a7d29068e465aa60516891e95854e3ef23231d28bc0766/pybase64-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:816fccccaa736743c19f8fd687def788e0c0813f8168f88c4d169827b6726d65", size = 100106, upload-time = "2026-08-08T15:39:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4e/8c7ce1a990e44598b97540b4dd92bf511190da6663d5c4c76c58487279e8/pybase64-1.5.0-cp314-cp314t-win32.whl", hash = "sha256:a18c7dfab52b07453321b24e5be2d532e7875076e67b7295b5b471988616b541", size = 42948, upload-time = "2026-08-08T15:40:01.078Z" }, + { url = "https://files.pythonhosted.org/packages/af/48/006c6c76f7957dc08c06b5057bfd85f7e2bfefe57bd7719de6e80eb30cea/pybase64-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0d1c371e90556712ec937ded4fe1986176e01ce9568749f98c562115a427ab2f", size = 45268, upload-time = "2026-08-08T15:40:02.229Z" }, + { url = "https://files.pythonhosted.org/packages/31/71/21d5b0cd2350e4dbee886e459acd4aa0a980086dd211458b185916e9bdc0/pybase64-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2b01763ed71f190651fe53faa1ec5e41ed8d6c730d0f32f25da8afff07b119", size = 40275, upload-time = "2026-08-08T15:40:03.466Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3a/1d8a9bbcedfe9bb52eb1e67527e0d11b7a5c4e1bb311924b3b181211cc30/pybase64-1.5.0-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:20e4c838a84fad3491027f0bd364f6fe21eedecab51860078b23cdb22bcb016d", size = 44702, upload-time = "2026-08-08T15:40:04.648Z" }, + { url = "https://files.pythonhosted.org/packages/8a/b0/97bf3c4f807eb68f3c53a5b59df784db8513bcd6ff8cef7e3f0de0e8d4ef/pybase64-1.5.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:20f18edb511ccfb652e114d985a61a4201f9d60bf5a3b3f9e6e95caf3a2f7859", size = 50119, upload-time = "2026-08-08T15:40:06.061Z" }, + { url = "https://files.pythonhosted.org/packages/83/66/5bd414d1dd9aaa1b3c108108f1a9c0d3de6192d8a8753a674c084429a654/pybase64-1.5.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09ba0119df1766bb43ae9774df511b396b89bde68a797119366aca1292f83eac", size = 39873, upload-time = "2026-08-08T15:40:07.232Z" }, + { url = "https://files.pythonhosted.org/packages/df/2d/047976dfb83c30be4849bfee783fc45d0e2fdec9115939acf220fc95e9b6/pybase64-1.5.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e3ed723ed56d273b0e3a45c2583c5566ccb39cc5fd4d335bdcbe235f84e1a211", size = 40356, upload-time = "2026-08-08T15:40:08.767Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0a/3927d8d51cfcaf603f0065809a90f31cc5b4f98386eb58f5ccb0fc28bafc/pybase64-1.5.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dd1ace6dacffce5cdbe68a3b2efdf22e3c890a906d887075e10dcc5f4124068b", size = 46956, upload-time = "2026-08-08T15:40:09.917Z" }, + { url = "https://files.pythonhosted.org/packages/56/f7/d9a82433b00952bb64323092913ab1facbdedae097955ddf2b6222686196/pybase64-1.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e8d559a46759687accc1780fbb07be17f663746842853c88115cbf89c680fb4e", size = 47554, upload-time = "2026-08-08T15:40:11.037Z" }, + { url = "https://files.pythonhosted.org/packages/57/08/116876cc8c371a2e10a1a2d870cae935195eba8355b220272798e2897186/pybase64-1.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:b51c308c5732bf4fe5ff6edfd4bced2a32bf41fe664cafc3088d3cff7566734b", size = 40779, upload-time = "2026-08-08T15:40:12.239Z" }, + { url = "https://files.pythonhosted.org/packages/42/fd/40e2339ac17c4b877c9588867144ca15eaab4644e3f59e494b831d73a770/pybase64-1.5.0-cp315-cp315-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:40399e568324635235697b00410634e0fb027432e9b9fef92886eb3407a5c211", size = 90912, upload-time = "2026-08-08T15:40:13.823Z" }, + { url = "https://files.pythonhosted.org/packages/82/e6/5eea2e16c31af2f5c31a7df903df05fb94e146b80709252b0e4da3a09cd9/pybase64-1.5.0-cp315-cp315-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92dbad4599d5d081f905bba43b10690cc4d445857d04a7b18eba1a09bfa27cf6", size = 95172, upload-time = "2026-08-08T15:40:14.991Z" }, + { url = "https://files.pythonhosted.org/packages/72/45/502b486cd801297984f302558973364669b97d1a50e9867e12afa30b5d86/pybase64-1.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1e571d2db1c515641e9918cf04f23be58818ba6d56f266fab31dfc6d5f6e01d9", size = 84916, upload-time = "2026-08-08T15:40:16.276Z" }, + { url = "https://files.pythonhosted.org/packages/7a/00/2c9100bf1f651cf3a6a2c835306603323e3d05c9d2fe7d8ee3727bb7a718/pybase64-1.5.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:1e3f5f726bedde8d7006c4f8d61f0f053de65b806af24110278c530445b6da50", size = 88363, upload-time = "2026-08-08T15:40:17.636Z" }, + { url = "https://files.pythonhosted.org/packages/40/9e/fb4520a0cd238065141a89f9f2b6c54ef4e9ff6578ffba6122b5f9af24b1/pybase64-1.5.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:107129bf5591f040cd6cfe3b7ea5c1626a2f9610763e54d450778c578ca2b69a", size = 84714, upload-time = "2026-08-08T15:40:18.809Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6b/87fd652e2c63516dfae373b9563329af2c5baf67a29a499601861cf52d89/pybase64-1.5.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:e161a4ba46caaa9417d5cd55f23c0717d5243b4f2a96c176b0d1a07bf86e0b0c", size = 83687, upload-time = "2026-08-08T15:40:20.192Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ee/b083e2092dd1b6a6a973ced22b3363dc0bb27e7e2b21da8d83b44097d523/pybase64-1.5.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:741f944bef8dd709e9ca9e991f5f6a91a8d49b6e2725fdb4070027f0ec06faa2", size = 88276, upload-time = "2026-08-08T15:40:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/11/ff/498c12d1316594d3796795e66301e193afa4e51fdd7e378c087922bfb074/pybase64-1.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:4c94d6b104411d33df813b1defa8a1194a884e9393839fefa3f7ea7377e1efeb", size = 83045, upload-time = "2026-08-08T15:40:22.974Z" }, + { url = "https://files.pythonhosted.org/packages/da/03/755d6316c7cfdab904311900aafbbbf1ed2227ae814bd3d2f25df8d10d46/pybase64-1.5.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:0976e9b7465387038868c6b560d7cdcbb9ef5214faf55ae6036e4aa4e93ba423", size = 77577, upload-time = "2026-08-08T15:40:24.242Z" }, + { url = "https://files.pythonhosted.org/packages/8f/c5/ebd26ad4032442d6fa6ba14ed0a222bd5d81f2a373d4d83a840a432e6c15/pybase64-1.5.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:6fa782fc5d7d53bb4c1b01e34909287f301c4c81251f8130e55848ab5d2f23e1", size = 90946, upload-time = "2026-08-08T15:40:25.462Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5a/46f96588a494ab8ab71a3fae5a18627c728c0379ae96af96312e54f8c8e6/pybase64-1.5.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d3e26e250aa51813881d03c09995a41462e115ab9c3c2b6d5202e4286b924d00", size = 82163, upload-time = "2026-08-08T15:40:26.745Z" }, + { url = "https://files.pythonhosted.org/packages/21/29/263bb998064c5d18957f5445a381f336070d24f09e7c372a0e3963dea142/pybase64-1.5.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:f4135c1e12615fa7989c9aec4720cedaa342bc4b8dbd5665f84a95790e3db5fd", size = 87170, upload-time = "2026-08-08T15:40:27.958Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/e5b3e991ebcc71d20ae9246011dde389e321f450b658976be7a51ca50824/pybase64-1.5.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:6ae263c1244bf375420fcdfd5ab32d463496f3814177edc8f0f3a8b56d7fe643", size = 81041, upload-time = "2026-08-08T15:40:29.641Z" }, + { url = "https://files.pythonhosted.org/packages/6b/37/a6e17849a37cb94b010b4eb7decaa5b49b6fafd0d18b386bd1cbe4b4d523/pybase64-1.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d0930504fbe5c003f31d67aeab4b8f155a409168a26ef8ea7df759bc50ab6729", size = 93974, upload-time = "2026-08-08T15:40:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/27/58/9d9f38918c9b27ac7200302fde0baecb95daf3b8a3cbc917238291691134/pybase64-1.5.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:9edbf7e7a97454904a4ccfbd007a511b75ebf13cba9d0dbdfe6c4480e154edf6", size = 33157, upload-time = "2026-08-08T15:40:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/8b/61/aa5c8775a93b2833b016c620dea0debcde2169e6a377008c7d0d34e0d640/pybase64-1.5.0-cp315-cp315-win32.whl", hash = "sha256:eed1b552f5979a4e3545dbaed4dd8111af9d321844232945bd0ed3a505602dd0", size = 42700, upload-time = "2026-08-08T15:40:33.408Z" }, + { url = "https://files.pythonhosted.org/packages/ba/26/b0e1f7dac48ad2b57652dfd3efdef9d0d3f251184802bebc584c6a3a4014/pybase64-1.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d5a27a14899cb1b878c2924dd150d943c4e5cee02a50a409a1f62f4ad852038e", size = 44864, upload-time = "2026-08-08T15:40:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/51/bb/a9e218f092eabf20e893f3490b1fe334c410c0c5851a2a87cfe7157ef3df/pybase64-1.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:163586e9ec367158b0f744ae12d27a28381f85dce7b90a4f9aaa901b1fa06d74", size = 40139, upload-time = "2026-08-08T15:40:35.939Z" }, + { url = "https://files.pythonhosted.org/packages/40/b2/d89caf52c642eceda40c074c4d881cb68b560bf379ce90c44821d79df64f/pybase64-1.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:dc96f63170b2fc943ac83da1015c6333cbaf251d12174b6e506315b941dd16b5", size = 47848, upload-time = "2026-08-08T15:40:37.158Z" }, + { url = "https://files.pythonhosted.org/packages/b8/29/baa2a610ac72c560c29965c0c5b937af0c5ac48055ce3c3afaf1ab329b6d/pybase64-1.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0eb9489fe31db090f95affe81fea96c3dab51c24593ce14fef936ce92d802b63", size = 41197, upload-time = "2026-08-08T15:40:38.345Z" }, + { url = "https://files.pythonhosted.org/packages/3f/da/1cdd664628bef3b6108fac20e2a11df8992e1b0d5a1ff1336256d8817961/pybase64-1.5.0-cp315-cp315t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f8dcf39b6aabed5d3820188451e98d651a9fde2453a2e99fb386941d4bd518d9", size = 97813, upload-time = "2026-08-08T15:40:39.505Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/430bd2afbd179a278c87e282062db898735a9dc17255b223f1c0d4276b5f/pybase64-1.5.0-cp315-cp315t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ee57900cb5d35a79d992800103180d715b68d8b56658b445a10f97e8805982", size = 102425, upload-time = "2026-08-08T15:40:40.745Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a7/6a80063f72ba5bf09bc43a26ad5bb6152a1fc52fec75f7b24d40ec25c37c/pybase64-1.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5d1c9d46d6b8459f5dac87b1778950ad28e27a83d1cdba1d2c34a031dcd57e2", size = 93851, upload-time = "2026-08-08T15:40:41.957Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b3/dfc900a5a724452defb33dff71a869638e4e58497dc7fe20602d6e650b64/pybase64-1.5.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:84e619e315fdaf8b70d54cdd0be12c7895dcdcd0212a42a67576b33f7af111dd", size = 92634, upload-time = "2026-08-08T15:40:43.164Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/da8aeb775098fd53d55c289089e6fc94b37751d156e130d11f8c137caf8e/pybase64-1.5.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:80eb2c568f1f09283ad7528407a97e84935f23851943ed27206b52664b8010f0", size = 90805, upload-time = "2026-08-08T15:40:44.357Z" }, + { url = "https://files.pythonhosted.org/packages/df/93/28a50e85bab801e642762f71d852ae765970a0df8b9915848e822b73d64a/pybase64-1.5.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:69a2c6eaaa3b7e157ddd1c3803d09e5fa80d9aeb5191b81ad60e182662c2a324", size = 89140, upload-time = "2026-08-08T15:40:45.599Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/17c43a329d20a299b25a01a425dfd5f671274a5ad65754ca314720ca9f24/pybase64-1.5.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:e1df96c88f8e9f57cbe25f0d8f28411e2d1cc42be26e99078f6e4efa876dcb96", size = 92235, upload-time = "2026-08-08T15:40:46.838Z" }, + { url = "https://files.pythonhosted.org/packages/11/97/e42e428a4da55f56d873afc555ff18a91e2932a6a44b4367a9c072d09c03/pybase64-1.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:16201c0998c80f0bac0817a792969b7e1f4169014a8a6b32019e005384734805", size = 91740, upload-time = "2026-08-08T15:40:48.108Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f5/4a527a34c2742009376dba884e84b0e34e44253f5e6b951c66494dff488d/pybase64-1.5.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:f5d28afc34ee925f0beb376d2e3ace38267e700994481511686f2b467f11f51c", size = 85993, upload-time = "2026-08-08T15:40:49.352Z" }, + { url = "https://files.pythonhosted.org/packages/43/9b/50bda3bd73f0f20e83a7941b98e5c655ba1cb6d0d9228c192e3b2ee7ea56/pybase64-1.5.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:dc719c38087e09788d40216ebaacc89504dd8e964c0457085a4c1b83695eaa5b", size = 97768, upload-time = "2026-08-08T15:40:50.593Z" }, + { url = "https://files.pythonhosted.org/packages/11/87/befa9e85b22f32b8eadbdc1145f61ebb16d571923954ac258ddb7f96958f/pybase64-1.5.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:7b809817bf0413bcc00cab69d6a055e1fb2626b22359772c2c3570ac3fef7462", size = 87872, upload-time = "2026-08-08T15:40:51.91Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b0/5b213e770f5e8f3df9a09d4337821a7ffd5001e56a248ebf782a6a8bbce7/pybase64-1.5.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:e953b14d562b7c08eae7b7c327b5162c78a6975974d8de8d7acff2b8b7c682b0", size = 92278, upload-time = "2026-08-08T15:40:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/75/58/1751447e2f3d480dad36d9d0f4a18a65062551ada9cf5a18599a79583536/pybase64-1.5.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:8a5aaa4343b5ed1af3850ce351482e7385d695af15b81b244c3f823949dfe796", size = 86299, upload-time = "2026-08-08T15:40:54.516Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/d505ad3dab5ae4339d0ae471453a305ea7cbe9be630825fb06019d18fe0d/pybase64-1.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a80b502057361c8f2f5f9b75ecda9127b4ea1b1baec7b99b63d425c09e799b12", size = 101313, upload-time = "2026-08-08T15:40:55.813Z" }, + { url = "https://files.pythonhosted.org/packages/ee/12/dfdea7f9d67339a32a5fd85e522b4d1d52f6200dbd29c4cdf190c0802f16/pybase64-1.5.0-cp315-cp315t-win32.whl", hash = "sha256:925f34f75e024abe94dd0f33da8f0cb21db35f85d534219dc18abde90c06a8d7", size = 43070, upload-time = "2026-08-08T15:40:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/29/2d/517ea25a383585fba3b0c5d86a68b72782f45de7dce55c4324fe6c9bb69c/pybase64-1.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:15b0ac4dc01be9a7d2a3e508720a8e3aea9f0dfb1a3dd62b7d5a23f35e76ee7d", size = 45399, upload-time = "2026-08-08T15:40:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d3/9a61bd2e91a45e51806da7e3ab8c6957ba5d83f4f16e56af32a98c14a97c/pybase64-1.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:ee074ecc63f43c664a35c9aea9daa84ab9d0de24487353f53aed097012c8d43c", size = 40432, upload-time = "2026-08-08T15:40:59.787Z" }, + { url = "https://files.pythonhosted.org/packages/e4/99/9cc7eadd3dcc3b9d814a15381fe78bc59dff133d25ba3a8e49e4380fff30/pybase64-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:a9bcbdefd0858372c2e3c657ca8c1e2cdf0af5963cb45085cc861dfac0ddd422", size = 48565, upload-time = "2026-08-08T15:41:27.275Z" }, + { url = "https://files.pythonhosted.org/packages/77/04/0b073d5fe8d035c3334d44252218e82ca0717f71a1139efdbc1600c38463/pybase64-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:8b47a5b4a359e42b4b726cbd9558347c5324194aadaf12e4ad219efc89dc9812", size = 43122, upload-time = "2026-08-08T15:41:28.596Z" }, + { url = "https://files.pythonhosted.org/packages/ba/dc/cd57bd8629965d69eaaa721cf915f3c0590ba468811d290bbcdd3908f0ee/pybase64-1.5.0-graalpy312-graalpy250_312_native-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b618ecec8f13b3f9dd58e257aa98fc9b017829a1bdc4f576e9146998956ec2c7", size = 54270, upload-time = "2026-08-08T15:41:29.872Z" }, + { url = "https://files.pythonhosted.org/packages/aa/22/67ad2ddf8ed03e0fc94341ebfc6ed694a36b9c908dd5a08b3ca366e31892/pybase64-1.5.0-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d09d63b219adfb1b40e104036dc2462234d2f06c05e436918e08f31a09a973b", size = 45919, upload-time = "2026-08-08T15:41:31.177Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bb/4d080faff127cc8e5e0f5f6bb94d3a079235f83d0ef7355663f4bf214935/pybase64-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:b059b951347a6e16d29b1488f624a7b213c7e8482869b1eac2b684e6fb1ac236", size = 45025, upload-time = "2026-08-08T15:41:32.601Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c1/a45419d6798db99f722c98af153c070964ba8f65c276cb9f771b1407ed05/pybase64-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:abb4aedb092aca028e1111998a0c2a5b6e327707e61df2c22e061118b0a8ccd5", size = 46862, upload-time = "2026-08-08T15:41:34.432Z" }, + { url = "https://files.pythonhosted.org/packages/87/8f/1a25e4067c972560e7f509c814020e946201288d546dbf1882b445b77094/pybase64-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:dedecea1ef347db51736836fb609168ab376cdb956a5ded576f271054fba0efc", size = 40205, upload-time = "2026-08-08T15:41:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/d5/27/26473f003bdbe6d8fe79904ae23ab99e5ccb8ab4e28e147495f5688358aa/pybase64-1.5.0-pp310-pypy310_pp73-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:1439d84162a4ee5598ff324b63f651d9c5adaaa9fce271764384cd55f50bcf2f", size = 50290, upload-time = "2026-08-08T15:41:37.09Z" }, + { url = "https://files.pythonhosted.org/packages/8d/51/0ca9973a6eff45ad7351dac69db42fce4e07b18e2b90e3274800cfcf6262/pybase64-1.5.0-pp310-pypy310_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6a005b8dcc724f0dae96d0504f93d16a283d9a79bdaeee57648335ff0b483470", size = 50765, upload-time = "2026-08-08T15:41:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/f4/05/994876865682591276e95cf19affa229a1026efa9f4c352911ad0bac807e/pybase64-1.5.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:14e4eb6091afd1cf956a37a331566c453aa080fd692acfa76f35761a04f19fd0", size = 45234, upload-time = "2026-08-08T15:41:39.669Z" }, + { url = "https://files.pythonhosted.org/packages/44/f7/e4f8151070be79b13aa276490981abbc283d0a983918cc052d4f02b21a4f/pybase64-1.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:50eea4c9a05308fbae30ee976150e7416baade27970ae8e229174ce92b5e07bc", size = 44693, upload-time = "2026-08-08T15:41:41.378Z" }, + { url = "https://files.pythonhosted.org/packages/88/84/d011c9b098996db666cb971a831c715d693209e39d28690af1b8049ce3fd/pybase64-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:89756a61cd09a5669ce923081f518476ff4b960c5d850a5dd54f0cf4406ac684", size = 46808, upload-time = "2026-08-08T15:41:42.904Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/8d6c3aea8fc68875a9d1b4fa750911a2e2e019f984498f7a21807fd0cbed/pybase64-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0e8691ad425ab8c586ad93a2d71789c6ab86e201377619ea146ab0ed092aa2ab", size = 40032, upload-time = "2026-08-08T15:41:44.26Z" }, + { url = "https://files.pythonhosted.org/packages/86/d9/399c45ada7e401c927345324baf797b167864b9817be6aa71a1e28a00ad1/pybase64-1.5.0-pp311-pypy311_pp73-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:adfc52cee3ad56c070e824bee9feda1f13c8679601ff8d0535f03da60bdcdda6", size = 50312, upload-time = "2026-08-08T15:41:45.607Z" }, + { url = "https://files.pythonhosted.org/packages/62/18/064288c6211c79a27893b70261558cf79a254ca22d80101bd7d05a817a6f/pybase64-1.5.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d240554e1a63ad9b7cb128acf94d4bc7d8400c78dfb76521775e767d4aa0b22", size = 50782, upload-time = "2026-08-08T15:41:46.855Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/d68d5df0f367613643c9cb9470a25611d2d078471c1eadeb86c00e644182/pybase64-1.5.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b7af9ad847b351b42ec54b3c0580febe406b28408917b7fc1565c87896ed0c4d", size = 45251, upload-time = "2026-08-08T15:41:48.082Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/1dc6e9d781a00fc427518ba462d6ed5b15caaabe3aa74aaca24b2b68ad26/pybase64-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:83496c6800d5e1002d1e923ab5bef49bb67a07c2faac8374364497182f04af72", size = 44720, upload-time = "2026-08-08T15:41:49.384Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-ai-slim" +version = "2.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "genai-prices" }, + { name = "griffelib" }, + { name = "httpx2" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pydantic-graph" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/ba/27fc9f4b2ecf0db16f9f8b79a07b0f7a0770e2e91b6a488821d868908895/pydantic_ai_slim-2.32.0.tar.gz", hash = "sha256:2b3faa16cd183f6797524e472d7749c29c2bb9161e05c5bc15e5d5e60d1770af", size = 1225008, upload-time = "2026-08-19T04:02:53.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/78/1d996f58f415f7ed032992b4c003def59c4c9200947cdfe41abec43fd969/pydantic_ai_slim-2.32.0-py3-none-any.whl", hash = "sha256:4bf57077c6b928fa1ab44069736a853d1d8754652ea25060e32b03fd6bcf6269", size = 1443791, upload-time = "2026-08-19T04:02:46.842Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-graph" +version = "2.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "logfire-api" }, + { name = "pydantic" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/4d/17f1b887563f1082f286df4e89e95e44b3963fb730d1973e08ba8d9d4fa6/pydantic_graph-2.32.0.tar.gz", hash = "sha256:d3a2189105af1734bb2245fc12db0957cab9fd3f2fd7af9bfbeb1e02b79bf640", size = 45163, upload-time = "2026-08-19T04:02:55.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/bd/2d32bbfd4abf3682ef1fb5b99265a70bff1b1f87da53e7b8f8e351e3c4b1/pydantic_graph-2.32.0-py3-none-any.whl", hash = "sha256:db85d62de6126aa4a7b61f04caa7c07f76ac0f5fb36b5b70db6abcd955aeeaf1", size = 52648, upload-time = "2026-08-19T04:02:49.665Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pypdfium2" +version = "5.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/78/a52cb80611339ec95f35c7a10d7bfe7a6f97f3b50a35a9f94283d062512e/pypdfium2-5.13.0.tar.gz", hash = "sha256:7ca2d8e31bd8d0d40c496416b7d8bea423388669ffd494929f50e8c3a82326b8", size = 273639, upload-time = "2026-08-13T10:58:15.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/9c/a49050af85055054299c7fab658ac63f8fddde575774aecbf8f71c7a9e5f/pypdfium2-5.13.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:882f4bbd4b17a335b43603169a14cde9341de12b238acd5c39e690cbca7c4293", size = 3417299, upload-time = "2026-08-13T10:57:40.522Z" }, + { url = "https://files.pythonhosted.org/packages/50/ad/f23027328843ee2bdd05afe16bb101f5906befd0c70de35fa8c53f60a5ff/pypdfium2-5.13.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:d96929bde3bd64c771ab3558ca1ffd7704cc4d872ab92cd9f8f8b8a20f7f36b8", size = 2864708, upload-time = "2026-08-13T10:57:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/08/99/1fe58428b69d2722dcbcfaa08ce71834a332c5b518fd58874bcef936b823/pypdfium2-5.13.0-py3-none-macosx_13_0_arm64.whl", hash = "sha256:da5c7b74eebf40b5c1fbe1de01aa1edc8827a79fb1efd999616bc20dcaf77ba4", size = 3507415, upload-time = "2026-08-13T10:57:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/06e26da88a4f5b4ed289325868717a186020661b7b221aa6df622711d31b/pypdfium2-5.13.0-py3-none-macosx_13_0_x86_64.whl", hash = "sha256:2abedfb5c70992b19c780ed58d7f7b929e8ce8ee52c9140158f44317c90ec6c7", size = 3670979, upload-time = "2026-08-13T10:57:45.607Z" }, + { url = "https://files.pythonhosted.org/packages/fe/31/f8210d53775f142be934336665b1d60e800c3f176f28c29b4908d945c518/pypdfium2-5.13.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ee8c2bb2e68b396ab4a763215ac100dacb6b96d0da5bebeb239a021aecc3a7e", size = 3676486, upload-time = "2026-08-13T10:57:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/94/50/d339fa09fbe592564b100bfc76833170a1104a764a458ac2abfffcb632f2/pypdfium2-5.13.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07f58e91b8c45ca144a1ff3008faf3c73ef8a5e9fb32988831788363288228cd", size = 3400883, upload-time = "2026-08-13T10:57:49.189Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e0/b10cf41b5e9f0212d014c40635659c6ab95bb4fcc6fc47f5d3c571f8d57f/pypdfium2-5.13.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:46b2f5be9e7ae941ee4216e3d20b66f9dc3d81944a3d57756272de5275204709", size = 3803912, upload-time = "2026-08-13T10:57:50.865Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d8/25ba4ce9a9059ece82f4514df0658fde0aa9bbeafe135e76017c052bf56f/pypdfium2-5.13.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d96beb7f379e6c76d874ca93fcd182ac3168dd499056407070f9927fb1061b8e", size = 4218231, upload-time = "2026-08-13T10:57:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7c/74a2fb48e5b0d2402d9ca64b39074c722d67e9a8a2c58449a843a8c2329a/pypdfium2-5.13.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81df25c1ab4c13ff773102d3cbea1967511d079123b067fc077bd0c4d57d91d8", size = 3730077, upload-time = "2026-08-13T10:57:54.021Z" }, + { url = "https://files.pythonhosted.org/packages/59/12/8c922f00518c26dc47d3676cc09c1d3c95e991c1977e31067d23cc2215cb/pypdfium2-5.13.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d66a32d89fa5b4a2715810171239eb194df4aba604727483ab760512f3c6a851", size = 4031512, upload-time = "2026-08-13T10:57:55.736Z" }, + { url = "https://files.pythonhosted.org/packages/c6/48/a171d034c2dac01adcc57d3dad3c97ba11f19d916f421176002c9e02c904/pypdfium2-5.13.0-py3-none-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b90b0a5ac310bb34db8eb848e58fcab4e201e124e3cf3cb1ccb7b85293e034af", size = 3995485, upload-time = "2026-08-13T10:57:57.39Z" }, + { url = "https://files.pythonhosted.org/packages/36/2e/dcb24776d409bb9e5b7fb26a0c62a87b98ab0e30dfcca645eaf31e35123b/pypdfium2-5.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ada81c36483cd61d07e32bc7814620ee96256b4f421b913f566861bf91800248", size = 5016636, upload-time = "2026-08-13T10:57:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/93/24/1fab8470fc6de6f4481f009c90757b1a1ee0a61d8e864ed273f72ffca855/pypdfium2-5.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3826e521e895648983cb9ee6b934d4bf51552600043984f84e9c2b3b14b696f3", size = 4555251, upload-time = "2026-08-13T10:58:00.753Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ef/6e8dbea1eddcb55cf34172753ffccd39566333c803cc94d43c653f369f2f/pypdfium2-5.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5c029d7163a91f264eafab51fb442a84a33efd9fd83d5a06c0136a7857a3cc8d", size = 5263483, upload-time = "2026-08-13T10:58:02.48Z" }, + { url = "https://files.pythonhosted.org/packages/53/fe/2ff673730189a621c01f9193c74b0f6aa70d8740889fdf11949e1c541869/pypdfium2-5.13.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:be2dccbde0ce7efe334ecd8f348df4308db360756ede4f0821d82dfc9a58caa8", size = 5144135, upload-time = "2026-08-13T10:58:04.351Z" }, + { url = "https://files.pythonhosted.org/packages/19/0b/759b9037c007317fa5c990dd3f6eff2b99d3fbced251d1e2512be92f2e2e/pypdfium2-5.13.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:bcd81394fe101405e026eedb3e40bef84635c1e5d974dd6036420eb6937753c6", size = 4648156, upload-time = "2026-08-13T10:58:06.036Z" }, + { url = "https://files.pythonhosted.org/packages/db/3b/ffe29679c52efe8eb02d77aa6656e6d6201395423329af018ebd5923a3d0/pypdfium2-5.13.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:2ed32ff685f8e05e637c990bedbf5fca66727bf27718d8bc33eeab21ce0630d1", size = 5089852, upload-time = "2026-08-13T10:58:07.791Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b6/cebacc1601ddfdcd1e6a1dc321533d215ceccf9b825fa9b91b11c6dc39fb/pypdfium2-5.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9c777edba28d1d5fd15435ed3a78ee2fdb93dd069be37cb53b559bc122793770", size = 5074153, upload-time = "2026-08-13T10:58:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/54/40/cf14c4f534f817788966857afdedb90002198dca5ce4fe2c6ecb031955ae/pypdfium2-5.13.0-py3-none-win32.whl", hash = "sha256:d33ee7077db67478b75efe4b5ea9610fb96c5416a0bc4949227f0f59c34dfcd9", size = 3753164, upload-time = "2026-08-13T10:58:10.97Z" }, + { url = "https://files.pythonhosted.org/packages/5d/99/a37b6b902457569468ed5908c94e56cb6c4032541f02cf89f723d42a9148/pypdfium2-5.13.0-py3-none-win_amd64.whl", hash = "sha256:47dcca2a8d507b5fd24f94c3c9d48fb379430f097bc20f01beff6c963ffbcedb", size = 3885553, upload-time = "2026-08-13T10:58:12.709Z" }, + { url = "https://files.pythonhosted.org/packages/50/7f/d39f6e64375c2ffd50ea100e3c73af79085c880c2791eb7203bc61d8913f/pypdfium2-5.13.0-py3-none-win_arm64.whl", hash = "sha256:554a0b23376460af1410e3c915906895e2dac67a086b9e6ccde0643a795d3b0d", size = 3700026, upload-time = "2026-08-13T10:58:14.206Z" }, +] + +[[package]] +name = "pypika" +version = "0.51.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/78/cbaebba88e05e2dcda13ca203131b38d3640219f20ebb49676d26714861b/pypika-0.51.1.tar.gz", hash = "sha256:c30c7c1048fbf056fd3920c5a2b88b0c29dd190a9b2bee971fd17e4abe4d0ebe", size = 80919, upload-time = "2026-02-04T11:27:48.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/83/c77dfeed04022e8930b08eedca2b6e5efed256ab3321396fde90066efb65/pypika-0.51.1-py2.py3-none-any.whl", hash = "sha256:77985b4d7ce71b9905255bf12468cf598349e98837c037541cfc240e528aec46", size = 60585, upload-time = "2026-02-04T11:27:46.251Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[package]] +name = "pyreadline3" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf", size = 100368, upload-time = "2026-05-14T17:55:04.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d", size = 85243, upload-time = "2026-05-14T17:55:03.262Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.1.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/d2/e6ee96b7dff201a83f650241c52db8e5bd080967cb93211f57aa448dc9d6/regex-2026.1.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e3dd93c8f9abe8aa4b6c652016da9a3afa190df5ad822907efe6b206c09896e", size = 488166, upload-time = "2026-01-14T23:13:46.408Z" }, + { url = "https://files.pythonhosted.org/packages/23/8a/819e9ce14c9f87af026d0690901b3931f3101160833e5d4c8061fa3a1b67/regex-2026.1.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:97499ff7862e868b1977107873dd1a06e151467129159a6ffd07b66706ba3a9f", size = 290632, upload-time = "2026-01-14T23:13:48.688Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c3/23dfe15af25d1d45b07dfd4caa6003ad710dcdcb4c4b279909bdfe7a2de8/regex-2026.1.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bda75ebcac38d884240914c6c43d8ab5fb82e74cde6da94b43b17c411aa4c2b", size = 288500, upload-time = "2026-01-14T23:13:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/c6/31/1adc33e2f717df30d2f4d973f8776d2ba6ecf939301efab29fca57505c95/regex-2026.1.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7dcc02368585334f5bc81fc73a2a6a0bbade60e7d83da21cead622faf408f32c", size = 781670, upload-time = "2026-01-14T23:13:52.453Z" }, + { url = "https://files.pythonhosted.org/packages/23/ce/21a8a22d13bc4adcb927c27b840c948f15fc973e21ed2346c1bd0eae22dc/regex-2026.1.15-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:693b465171707bbe882a7a05de5e866f33c76aa449750bee94a8d90463533cc9", size = 850820, upload-time = "2026-01-14T23:13:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/3eeacdf587a4705a44484cd0b30e9230a0e602811fb3e2cc32268c70d509/regex-2026.1.15-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b0d190e6f013ea938623a58706d1469a62103fb2a241ce2873a9906e0386582c", size = 898777, upload-time = "2026-01-14T23:13:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/79/a9/1898a077e2965c35fc22796488141a22676eed2d73701e37c73ad7c0b459/regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ff818702440a5878a81886f127b80127f5d50563753a28211482867f8318106", size = 791750, upload-time = "2026-01-14T23:13:58.527Z" }, + { url = "https://files.pythonhosted.org/packages/4c/84/e31f9d149a178889b3817212827f5e0e8c827a049ff31b4b381e76b26e2d/regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f052d1be37ef35a54e394de66136e30fa1191fab64f71fc06ac7bc98c9a84618", size = 782674, upload-time = "2026-01-14T23:13:59.874Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ff/adf60063db24532add6a1676943754a5654dcac8237af024ede38244fd12/regex-2026.1.15-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6bfc31a37fd1592f0c4fc4bfc674b5c42e52efe45b4b7a6a14f334cca4bcebe4", size = 767906, upload-time = "2026-01-14T23:14:01.298Z" }, + { url = "https://files.pythonhosted.org/packages/af/3e/e6a216cee1e2780fec11afe7fc47b6f3925d7264e8149c607ac389fd9b1a/regex-2026.1.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d6ce5ae80066b319ae3bc62fd55a557c9491baa5efd0d355f0de08c4ba54e79", size = 774798, upload-time = "2026-01-14T23:14:02.715Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/23a4a8378a9208514ed3efc7e7850c27fa01e00ed8557c958df0335edc4a/regex-2026.1.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1704d204bd42b6bb80167df0e4554f35c255b579ba99616def38f69e14a5ccb9", size = 845861, upload-time = "2026-01-14T23:14:04.824Z" }, + { url = "https://files.pythonhosted.org/packages/f8/57/d7605a9d53bd07421a8785d349cd29677fe660e13674fa4c6cbd624ae354/regex-2026.1.15-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e3174a5ed4171570dc8318afada56373aa9289eb6dc0d96cceb48e7358b0e220", size = 755648, upload-time = "2026-01-14T23:14:06.371Z" }, + { url = "https://files.pythonhosted.org/packages/6f/76/6f2e24aa192da1e299cc1101674a60579d3912391867ce0b946ba83e2194/regex-2026.1.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:87adf5bd6d72e3e17c9cb59ac4096b1faaf84b7eb3037a5ffa61c4b4370f0f13", size = 836250, upload-time = "2026-01-14T23:14:08.343Z" }, + { url = "https://files.pythonhosted.org/packages/11/3a/1f2a1d29453299a7858eab7759045fc3d9d1b429b088dec2dc85b6fa16a2/regex-2026.1.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e85dc94595f4d766bd7d872a9de5ede1ca8d3063f3bdf1e2c725f5eb411159e3", size = 779919, upload-time = "2026-01-14T23:14:09.954Z" }, + { url = "https://files.pythonhosted.org/packages/c0/67/eab9bc955c9dcc58e9b222c801e39cff7ca0b04261792a2149166ce7e792/regex-2026.1.15-cp310-cp310-win32.whl", hash = "sha256:21ca32c28c30d5d65fc9886ff576fc9b59bbca08933e844fa2363e530f4c8218", size = 265888, upload-time = "2026-01-14T23:14:11.35Z" }, + { url = "https://files.pythonhosted.org/packages/1d/62/31d16ae24e1f8803bddb0885508acecaec997fcdcde9c243787103119ae4/regex-2026.1.15-cp310-cp310-win_amd64.whl", hash = "sha256:3038a62fc7d6e5547b8915a3d927a0fbeef84cdbe0b1deb8c99bbd4a8961b52a", size = 277830, upload-time = "2026-01-14T23:14:12.908Z" }, + { url = "https://files.pythonhosted.org/packages/e5/36/5d9972bccd6417ecd5a8be319cebfd80b296875e7f116c37fb2a2deecebf/regex-2026.1.15-cp310-cp310-win_arm64.whl", hash = "sha256:505831646c945e3e63552cc1b1b9b514f0e93232972a2d5bedbcc32f15bc82e3", size = 270376, upload-time = "2026-01-14T23:14:14.782Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c9/0c80c96eab96948363d270143138d671d5731c3a692b417629bf3492a9d6/regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a", size = 488168, upload-time = "2026-01-14T23:14:16.129Z" }, + { url = "https://files.pythonhosted.org/packages/17/f0/271c92f5389a552494c429e5cc38d76d1322eb142fb5db3c8ccc47751468/regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f", size = 290636, upload-time = "2026-01-14T23:14:17.715Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f9/5f1fd077d106ca5655a0f9ff8f25a1ab55b92128b5713a91ed7134ff688e/regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1", size = 288496, upload-time = "2026-01-14T23:14:19.326Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e1/8f43b03a4968c748858ec77f746c286d81f896c2e437ccf050ebc5d3128c/regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b", size = 793503, upload-time = "2026-01-14T23:14:20.922Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4e/a39a5e8edc5377a46a7c875c2f9a626ed3338cb3bb06931be461c3e1a34a/regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8", size = 860535, upload-time = "2026-01-14T23:14:22.405Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1c/9dce667a32a9477f7a2869c1c767dc00727284a9fa3ff5c09a5c6c03575e/regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413", size = 907225, upload-time = "2026-01-14T23:14:23.897Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3c/87ca0a02736d16b6262921425e84b48984e77d8e4e572c9072ce96e66c30/regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026", size = 800526, upload-time = "2026-01-14T23:14:26.039Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/647d5715aeea7c87bdcbd2f578f47b415f55c24e361e639fe8c0cc88878f/regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785", size = 773446, upload-time = "2026-01-14T23:14:28.109Z" }, + { url = "https://files.pythonhosted.org/packages/af/89/bf22cac25cb4ba0fe6bff52ebedbb65b77a179052a9d6037136ae93f42f4/regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e", size = 783051, upload-time = "2026-01-14T23:14:29.929Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f4/6ed03e71dca6348a5188363a34f5e26ffd5db1404780288ff0d79513bce4/regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763", size = 854485, upload-time = "2026-01-14T23:14:31.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/8e8560bd78caded8eb137e3e47612430a05b9a772caf60876435192d670a/regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb", size = 762195, upload-time = "2026-01-14T23:14:32.802Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61fc710f9aa8dfcd764fe27d37edfaa023b1a23305a0d84fccd5adb346ea/regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2", size = 845986, upload-time = "2026-01-14T23:14:34.898Z" }, + { url = "https://files.pythonhosted.org/packages/fd/2e/fbee4cb93f9d686901a7ca8d94285b80405e8c34fe4107f63ffcbfb56379/regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1", size = 788992, upload-time = "2026-01-14T23:14:37.116Z" }, + { url = "https://files.pythonhosted.org/packages/ed/14/3076348f3f586de64b1ab75a3fbabdaab7684af7f308ad43be7ef1849e55/regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569", size = 265893, upload-time = "2026-01-14T23:14:38.426Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/772cf8b5fc803f5c89ba85d8b1870a1ca580dc482aa030383a9289c82e44/regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7", size = 277840, upload-time = "2026-01-14T23:14:39.785Z" }, + { url = "https://files.pythonhosted.org/packages/78/84/d05f61142709474da3c0853222d91086d3e1372bcdab516c6fd8d80f3297/regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec", size = 270374, upload-time = "2026-01-14T23:14:41.592Z" }, + { url = "https://files.pythonhosted.org/packages/92/81/10d8cf43c807d0326efe874c1b79f22bfb0fb226027b0b19ebc26d301408/regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1", size = 489398, upload-time = "2026-01-14T23:14:43.741Z" }, + { url = "https://files.pythonhosted.org/packages/90/b0/7c2a74e74ef2a7c32de724658a69a862880e3e4155cba992ba04d1c70400/regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681", size = 291339, upload-time = "2026-01-14T23:14:45.183Z" }, + { url = "https://files.pythonhosted.org/packages/19/4d/16d0773d0c818417f4cc20aa0da90064b966d22cd62a8c46765b5bd2d643/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f", size = 289003, upload-time = "2026-01-14T23:14:47.25Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e4/1fc4599450c9f0863d9406e944592d968b8d6dfd0d552a7d569e43bceada/regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa", size = 798656, upload-time = "2026-01-14T23:14:48.77Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e6/59650d73a73fa8a60b3a590545bfcf1172b4384a7df2e7fe7b9aab4e2da9/regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804", size = 864252, upload-time = "2026-01-14T23:14:50.528Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ab/1d0f4d50a1638849a97d731364c9a80fa304fec46325e48330c170ee8e80/regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c", size = 912268, upload-time = "2026-01-14T23:14:52.952Z" }, + { url = "https://files.pythonhosted.org/packages/dd/df/0d722c030c82faa1d331d1921ee268a4e8fb55ca8b9042c9341c352f17fa/regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5", size = 803589, upload-time = "2026-01-14T23:14:55.182Z" }, + { url = "https://files.pythonhosted.org/packages/66/23/33289beba7ccb8b805c6610a8913d0131f834928afc555b241caabd422a9/regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3", size = 775700, upload-time = "2026-01-14T23:14:56.707Z" }, + { url = "https://files.pythonhosted.org/packages/e7/65/bf3a42fa6897a0d3afa81acb25c42f4b71c274f698ceabd75523259f6688/regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb", size = 787928, upload-time = "2026-01-14T23:14:58.312Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f5/13bf65864fc314f68cdd6d8ca94adcab064d4d39dbd0b10fef29a9da48fc/regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410", size = 858607, upload-time = "2026-01-14T23:15:00.657Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/040e589834d7a439ee43fb0e1e902bc81bd58a5ba81acffe586bb3321d35/regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4", size = 763729, upload-time = "2026-01-14T23:15:02.248Z" }, + { url = "https://files.pythonhosted.org/packages/9b/84/6921e8129687a427edf25a34a5594b588b6d88f491320b9de5b6339a4fcb/regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d", size = 850697, upload-time = "2026-01-14T23:15:03.878Z" }, + { url = "https://files.pythonhosted.org/packages/8a/87/3d06143d4b128f4229158f2de5de6c8f2485170c7221e61bf381313314b2/regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22", size = 789849, upload-time = "2026-01-14T23:15:06.102Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/c50a63842b6bd48850ebc7ab22d46e7a2a32d824ad6c605b218441814639/regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913", size = 266279, upload-time = "2026-01-14T23:15:07.678Z" }, + { url = "https://files.pythonhosted.org/packages/f2/36/39d0b29d087e2b11fd8191e15e81cce1b635fcc845297c67f11d0d19274d/regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a", size = 277166, upload-time = "2026-01-14T23:15:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/28/32/5b8e476a12262748851fa8ab1b0be540360692325975b094e594dfebbb52/regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056", size = 270415, upload-time = "2026-01-14T23:15:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2e/6870bb16e982669b674cce3ee9ff2d1d46ab80528ee6bcc20fb2292efb60/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e", size = 489164, upload-time = "2026-01-14T23:15:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/9774542e203849b0286badf67199970a44ebdb0cc5fb739f06e47ada72f8/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10", size = 291218, upload-time = "2026-01-14T23:15:15.647Z" }, + { url = "https://files.pythonhosted.org/packages/b2/87/b0cda79f22b8dee05f774922a214da109f9a4c0eca5da2c9d72d77ea062c/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc", size = 288895, upload-time = "2026-01-14T23:15:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/3b/6a/0041f0a2170d32be01ab981d6346c83a8934277d82c780d60b127331f264/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599", size = 798680, upload-time = "2026-01-14T23:15:19.342Z" }, + { url = "https://files.pythonhosted.org/packages/58/de/30e1cfcdbe3e891324aa7568b7c968771f82190df5524fabc1138cb2d45a/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae", size = 864210, upload-time = "2026-01-14T23:15:22.005Z" }, + { url = "https://files.pythonhosted.org/packages/64/44/4db2f5c5ca0ccd40ff052ae7b1e9731352fcdad946c2b812285a7505ca75/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5", size = 912358, upload-time = "2026-01-14T23:15:24.569Z" }, + { url = "https://files.pythonhosted.org/packages/79/b6/e6a5665d43a7c42467138c8a2549be432bad22cbd206f5ec87162de74bd7/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6", size = 803583, upload-time = "2026-01-14T23:15:26.526Z" }, + { url = "https://files.pythonhosted.org/packages/e7/53/7cd478222169d85d74d7437e74750005e993f52f335f7c04ff7adfda3310/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788", size = 775782, upload-time = "2026-01-14T23:15:29.352Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b5/75f9a9ee4b03a7c009fe60500fe550b45df94f0955ca29af16333ef557c5/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714", size = 787978, upload-time = "2026-01-14T23:15:31.295Z" }, + { url = "https://files.pythonhosted.org/packages/72/b3/79821c826245bbe9ccbb54f6eadb7879c722fd3e0248c17bfc90bf54e123/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d", size = 858550, upload-time = "2026-01-14T23:15:33.558Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/2ab5f77a1c465745bfbfcb3ad63178a58337ae8d5274315e2cc623a822fa/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3", size = 763747, upload-time = "2026-01-14T23:15:35.206Z" }, + { url = "https://files.pythonhosted.org/packages/6d/84/c27df502d4bfe2873a3e3a7cf1bdb2b9cc10284d1a44797cf38bed790470/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31", size = 850615, upload-time = "2026-01-14T23:15:37.523Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b7/658a9782fb253680aa8ecb5ccbb51f69e088ed48142c46d9f0c99b46c575/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3", size = 789951, upload-time = "2026-01-14T23:15:39.582Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2a/5928af114441e059f15b2f63e188bd00c6529b3051c974ade7444b85fcda/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f", size = 266275, upload-time = "2026-01-14T23:15:42.108Z" }, + { url = "https://files.pythonhosted.org/packages/4f/16/5bfbb89e435897bff28cf0352a992ca719d9e55ebf8b629203c96b6ce4f7/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e", size = 277145, upload-time = "2026-01-14T23:15:44.244Z" }, + { url = "https://files.pythonhosted.org/packages/56/c1/a09ff7392ef4233296e821aec5f78c51be5e91ffde0d163059e50fd75835/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337", size = 270411, upload-time = "2026-01-14T23:15:45.858Z" }, + { url = "https://files.pythonhosted.org/packages/3c/38/0cfd5a78e5c6db00e6782fdae70458f89850ce95baa5e8694ab91d89744f/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be", size = 492068, upload-time = "2026-01-14T23:15:47.616Z" }, + { url = "https://files.pythonhosted.org/packages/50/72/6c86acff16cb7c959c4355826bbf06aad670682d07c8f3998d9ef4fee7cd/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8", size = 292756, upload-time = "2026-01-14T23:15:49.307Z" }, + { url = "https://files.pythonhosted.org/packages/4e/58/df7fb69eadfe76526ddfce28abdc0af09ffe65f20c2c90932e89d705153f/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd", size = 291114, upload-time = "2026-01-14T23:15:51.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6c/a4011cd1cf96b90d2cdc7e156f91efbd26531e822a7fbb82a43c1016678e/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a", size = 807524, upload-time = "2026-01-14T23:15:53.102Z" }, + { url = "https://files.pythonhosted.org/packages/1d/25/a53ffb73183f69c3e9f4355c4922b76d2840aee160af6af5fac229b6201d/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93", size = 873455, upload-time = "2026-01-14T23:15:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/66/0b/8b47fc2e8f97d9b4a851736f3890a5f786443aa8901061c55f24c955f45b/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af", size = 915007, upload-time = "2026-01-14T23:15:57.041Z" }, + { url = "https://files.pythonhosted.org/packages/c2/fa/97de0d681e6d26fabe71968dbee06dd52819e9a22fdce5dac7256c31ed84/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09", size = 812794, upload-time = "2026-01-14T23:15:58.916Z" }, + { url = "https://files.pythonhosted.org/packages/22/38/e752f94e860d429654aa2b1c51880bff8dfe8f084268258adf9151cf1f53/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5", size = 781159, upload-time = "2026-01-14T23:16:00.817Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/d739ffaef33c378fc888302a018d7f81080393d96c476b058b8c64fd2b0d/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794", size = 795558, upload-time = "2026-01-14T23:16:03.267Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c4/542876f9a0ac576100fc73e9c75b779f5c31e3527576cfc9cb3009dcc58a/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a", size = 868427, upload-time = "2026-01-14T23:16:05.646Z" }, + { url = "https://files.pythonhosted.org/packages/fc/0f/d5655bea5b22069e32ae85a947aa564912f23758e112cdb74212848a1a1b/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80", size = 769939, upload-time = "2026-01-14T23:16:07.542Z" }, + { url = "https://files.pythonhosted.org/packages/20/06/7e18a4fa9d326daeda46d471a44ef94201c46eaa26dbbb780b5d92cbfdda/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2", size = 854753, upload-time = "2026-01-14T23:16:10.395Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/dc8946ef3965e166f558ef3b47f492bc364e96a265eb4a2bb3ca765c8e46/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60", size = 799559, upload-time = "2026-01-14T23:16:12.347Z" }, + { url = "https://files.pythonhosted.org/packages/a5/61/1bba81ff6d50c86c65d9fd84ce9699dd106438ee4cdb105bf60374ee8412/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952", size = 268879, upload-time = "2026-01-14T23:16:14.049Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5e/cef7d4c5fb0ea3ac5c775fd37db5747f7378b29526cc83f572198924ff47/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10", size = 280317, upload-time = "2026-01-14T23:16:15.718Z" }, + { url = "https://files.pythonhosted.org/packages/b4/52/4317f7a5988544e34ab57b4bde0f04944c4786128c933fb09825924d3e82/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829", size = 271551, upload-time = "2026-01-14T23:16:17.533Z" }, + { url = "https://files.pythonhosted.org/packages/52/0a/47fa888ec7cbbc7d62c5f2a6a888878e76169170ead271a35239edd8f0e8/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac", size = 489170, upload-time = "2026-01-14T23:16:19.835Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6", size = 291146, upload-time = "2026-01-14T23:16:21.541Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b6/921cc61982e538682bdf3bdf5b2c6ab6b34368da1f8e98a6c1ddc503c9cf/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2", size = 288986, upload-time = "2026-01-14T23:16:23.381Z" }, + { url = "https://files.pythonhosted.org/packages/ca/33/eb7383dde0bbc93f4fb9d03453aab97e18ad4024ac7e26cef8d1f0a2cff0/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846", size = 799098, upload-time = "2026-01-14T23:16:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/27/56/b664dccae898fc8d8b4c23accd853f723bde0f026c747b6f6262b688029c/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b", size = 864980, upload-time = "2026-01-14T23:16:27.297Z" }, + { url = "https://files.pythonhosted.org/packages/16/40/0999e064a170eddd237bae9ccfcd8f28b3aa98a38bf727a086425542a4fc/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e", size = 911607, upload-time = "2026-01-14T23:16:29.235Z" }, + { url = "https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde", size = 803358, upload-time = "2026-01-14T23:16:31.369Z" }, + { url = "https://files.pythonhosted.org/packages/27/31/d4292ea8566eaa551fafc07797961c5963cf5235c797cc2ae19b85dfd04d/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5", size = 775833, upload-time = "2026-01-14T23:16:33.141Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b2/cff3bf2fea4133aa6fb0d1e370b37544d18c8350a2fa118c7e11d1db0e14/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34", size = 788045, upload-time = "2026-01-14T23:16:35.005Z" }, + { url = "https://files.pythonhosted.org/packages/8d/99/2cb9b69045372ec877b6f5124bda4eb4253bc58b8fe5848c973f752bc52c/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75", size = 859374, upload-time = "2026-01-14T23:16:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/09/16/710b0a5abe8e077b1729a562d2f297224ad079f3a66dce46844c193416c8/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e", size = 763940, upload-time = "2026-01-14T23:16:38.685Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/7585c8e744e40eb3d32f119191969b91de04c073fca98ec14299041f6e7e/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160", size = 850112, upload-time = "2026-01-14T23:16:40.646Z" }, + { url = "https://files.pythonhosted.org/packages/af/d6/43e1dd85df86c49a347aa57c1f69d12c652c7b60e37ec162e3096194a278/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1", size = 789586, upload-time = "2026-01-14T23:16:42.799Z" }, + { url = "https://files.pythonhosted.org/packages/93/38/77142422f631e013f316aaae83234c629555729a9fbc952b8a63ac91462a/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1", size = 271691, upload-time = "2026-01-14T23:16:44.671Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903", size = 280422, upload-time = "2026-01-14T23:16:46.607Z" }, + { url = "https://files.pythonhosted.org/packages/be/2a/20fd057bf3521cb4791f69f869635f73e0aaf2b9ad2d260f728144f9047c/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705", size = 273467, upload-time = "2026-01-14T23:16:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/ad/77/0b1e81857060b92b9cad239104c46507dd481b3ff1fa79f8e7f865aae38a/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8", size = 492073, upload-time = "2026-01-14T23:16:51.154Z" }, + { url = "https://files.pythonhosted.org/packages/70/f3/f8302b0c208b22c1e4f423147e1913fd475ddd6230565b299925353de644/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf", size = 292757, upload-time = "2026-01-14T23:16:53.08Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/ef55de2460f3b4a6da9d9e7daacd0cb79d4ef75c64a2af316e68447f0df0/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d", size = 291122, upload-time = "2026-01-14T23:16:55.383Z" }, + { url = "https://files.pythonhosted.org/packages/cf/55/bb8ccbacabbc3a11d863ee62a9f18b160a83084ea95cdfc5d207bfc3dd75/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84", size = 807761, upload-time = "2026-01-14T23:16:57.251Z" }, + { url = "https://files.pythonhosted.org/packages/8f/84/f75d937f17f81e55679a0509e86176e29caa7298c38bd1db7ce9c0bf6075/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df", size = 873538, upload-time = "2026-01-14T23:16:59.349Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d9/0da86327df70349aa8d86390da91171bd3ca4f0e7c1d1d453a9c10344da3/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434", size = 915066, upload-time = "2026-01-14T23:17:01.607Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5e/f660fb23fc77baa2a61aa1f1fe3a4eea2bbb8a286ddec148030672e18834/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a", size = 812938, upload-time = "2026-01-14T23:17:04.366Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/a47a29bfecebbbfd1e5cd3f26b28020a97e4820f1c5148e66e3b7d4b4992/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10", size = 781314, upload-time = "2026-01-14T23:17:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/65/ec/7ec2bbfd4c3f4e494a24dec4c6943a668e2030426b1b8b949a6462d2c17b/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac", size = 795652, upload-time = "2026-01-14T23:17:08.521Z" }, + { url = "https://files.pythonhosted.org/packages/46/79/a5d8651ae131fe27d7c521ad300aa7f1c7be1dbeee4d446498af5411b8a9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea", size = 868550, upload-time = "2026-01-14T23:17:10.573Z" }, + { url = "https://files.pythonhosted.org/packages/06/b7/25635d2809664b79f183070786a5552dd4e627e5aedb0065f4e3cf8ee37d/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e", size = 769981, upload-time = "2026-01-14T23:17:12.871Z" }, + { url = "https://files.pythonhosted.org/packages/16/8b/fc3fcbb2393dcfa4a6c5ffad92dc498e842df4581ea9d14309fcd3c55fb9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521", size = 854780, upload-time = "2026-01-14T23:17:14.837Z" }, + { url = "https://files.pythonhosted.org/packages/d0/38/dde117c76c624713c8a2842530be9c93ca8b606c0f6102d86e8cd1ce8bea/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db", size = 799778, upload-time = "2026-01-14T23:17:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/e3/0d/3a6cfa9ae99606afb612d8fb7a66b245a9d5ff0f29bb347c8a30b6ad561b/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e", size = 274667, upload-time = "2026-01-14T23:17:19.301Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b2/297293bb0742fd06b8d8e2572db41a855cdf1cae0bf009b1cb74fe07e196/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf", size = 284386, upload-time = "2026-01-14T23:17:21.231Z" }, + { url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837, upload-time = "2026-01-14T23:17:23.146Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rich" +version = "14.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version < '3.11' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.13.*'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "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.52" +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/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/d7/e0354e7334d33ea2795db3ecbe2977026c05a1ecf8ba4b5953c329872453/sqlalchemy-2.0.52-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a7438774e1091192fc50a2bd8ceff5c596912d00ecd46587e88effdea7826101", size = 2172616, upload-time = "2026-08-11T20:58:21.078Z" }, + { url = "https://files.pythonhosted.org/packages/5b/64/98eef682e6946eb1b4195a9a2393db4662ebfcc89f823ae78b938765c3c0/sqlalchemy-2.0.52-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c1b7ed45bf87b214e0a9def9c2313949067efe6269db5ef18d542ee13250af7", size = 3279798, upload-time = "2026-08-11T21:00:03.535Z" }, + { url = "https://files.pythonhosted.org/packages/20/05/5b96afc1407c314347ad006b72bb251fb68ef84d05505ebf8a39bc47fcde/sqlalchemy-2.0.52-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:309cc8ba50fc5d2174189dfcd49cdf7aa711f8346afcff19f2642ae4fc449c14", size = 3277555, upload-time = "2026-08-11T21:05:47.932Z" }, + { url = "https://files.pythonhosted.org/packages/50/69/ce6776724511d1b5dd40477b08d6a5f0953a45375e092dfa852b1857732c/sqlalchemy-2.0.52-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2f9eccf8793c8c3f8dd2dfd11b9e400cb27d1d19370ef732b66017e212107822", size = 3231246, upload-time = "2026-08-11T21:00:05.184Z" }, + { url = "https://files.pythonhosted.org/packages/72/19/ab0cb9ccdafa2419c796ae62f8740aedb903f1e93bb326064b1a0147e458/sqlalchemy-2.0.52-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9255ceb65a80c1b001129060b63ee776a2e9c288be3b662be36dfbb888fffdcd", size = 3250763, upload-time = "2026-08-11T21:05:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/41/f2/3c9b54b61bec4f493c0007dc9e2700c963c5b32667e21a506f1aca7a8115/sqlalchemy-2.0.52-cp310-cp310-win32.whl", hash = "sha256:2e15b1d1116a64fc399b8c2694a83f3e792fdc58df28514a81e1dc4f8cf22729", size = 2132169, upload-time = "2026-08-11T21:09:48.108Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a1/934bb6cf543a398c72784d1fc777eb530559c16ebe1549fa7611e5989ce9/sqlalchemy-2.0.52-cp310-cp310-win_amd64.whl", hash = "sha256:11560064cc4696e772298b6221ede59e646386d9f2a85d549365473b972f7850", size = 2156289, upload-time = "2026-08-11T21:09:49.429Z" }, + { url = "https://files.pythonhosted.org/packages/6b/08/cc5f7627b92f1456bc0b5fb7e98af4600248abe422a44da0d17a3fe6a448/sqlalchemy-2.0.52-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0c3ce43907374889f3352bdcc6195c970148a2cb71574cd0237a5071a37fb6c", size = 2172460, upload-time = "2026-08-11T20:58:22.429Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/9a2abad8bfc8fdcd38c64adc056aeefab7aaa96ecd32f5e8c140e6375f17/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0d48c4b80717c61385b4e966e087c839a66cfd7b780641dcb428f4dba65608", size = 3355720, upload-time = "2026-08-11T21:00:06.746Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/e75597b5841043e3c74055d00d4feb53d9a49a5c89ba2450d2d9aab53597/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938325a5373267afc53bfbe72983b20fbd64ca47842aac62433c3da1137ecff1", size = 3354394, upload-time = "2026-08-11T21:05:51.454Z" }, + { url = "https://files.pythonhosted.org/packages/12/25/410fbc6c2f1fa8310f4ef1b6847d47d0ac1c042c7b4e81eaaca063d030a9/sqlalchemy-2.0.52-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f8438a98d49424acf69d0d53c0a522951dfe49a6f2d86417fbb37ad3066ab43", size = 3306991, upload-time = "2026-08-11T21:00:08.603Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ba/25ffd5c24681ea4b46e62c80ceca8200ce204de1773366321306cf3f608a/sqlalchemy-2.0.52-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4699dbb8d396d199e7e78fd4d525e3ad3d6008a9c8c0160b87e74c606c2c3736", size = 3327454, upload-time = "2026-08-11T21:05:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f1/0f1b1d4800e51218e736a06ed55a3b2a59c257600bbaca7673bf13d2dbec/sqlalchemy-2.0.52-cp311-cp311-win32.whl", hash = "sha256:cef328349452ae152637df4d11ce5a0919ecdf0a363e16c830c3518ee33bde72", size = 2131248, upload-time = "2026-08-11T21:09:50.765Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f0/04d2ac5ad66f3d31278f37064ed5f5ef3fe653f7bdaa67036663f223d186/sqlalchemy-2.0.52-cp311-cp311-win_amd64.whl", hash = "sha256:f1c850792a3b25a3ad74dade3f05e4f402cdebfea27438bcadafaa1617f77bcc", size = 2156943, upload-time = "2026-08-11T21:09:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d5/1b77a026d161f98a08f11af1a5f6c47b98ee7c7e2648af525a1004826c78/sqlalchemy-2.0.52-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be8c49131665dfe2cc74c498aa1240ffb548d0fd901325dd11c2c7a18956f727", size = 2170940, upload-time = "2026-08-11T20:58:11.25Z" }, + { url = "https://files.pythonhosted.org/packages/54/bd/f444444adb37b5d53753fb1730ee7a421628e2e3b756c4da461af7e6394a/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b2d9e507a458832adcfbd8af6e2036ddf069b7710b799448542ebccae2dceee", size = 3383415, upload-time = "2026-08-11T21:02:38.534Z" }, + { url = "https://files.pythonhosted.org/packages/be/57/2eadf93a552568c57e8680b7e58bb5e9770d80942a1bdbaf4f2f63f0d7c8/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8738008376d22f30f411ea3efecf39b51110b6996d80bb73786f30bcfdd5fd3b", size = 3398577, upload-time = "2026-08-11T21:16:59.092Z" }, + { url = "https://files.pythonhosted.org/packages/15/c3/2887cf9dd111d1fbf05d22165b404c221ef43e029f7a2695e7302f27a7cc/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37a4d548327b6cab9c7d8cdb4e0e82feabee0110c4d150059068e2d1cfbd99ee", size = 3328225, upload-time = "2026-08-11T21:02:40.183Z" }, + { url = "https://files.pythonhosted.org/packages/02/0f/466bdf9e1feeeef5587f868c187d8687e21ff8c85b1775e9041130181132/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e49f51a5d59857a7a0dcaf9469febf7197d9394bd88f00d69c2c4e848112cdbf", size = 3357374, upload-time = "2026-08-11T21:17:01.076Z" }, + { url = "https://files.pythonhosted.org/packages/22/20/5c2b4583904af4173076dda1c9e53c9e2ffc7a702d2efde0216bbacbf7cb/sqlalchemy-2.0.52-cp312-cp312-win32.whl", hash = "sha256:afda3ec521d0517d0de783fc70030775841900896d832de5bbd066549290470e", size = 2129366, upload-time = "2026-08-11T21:14:50.991Z" }, + { url = "https://files.pythonhosted.org/packages/ed/06/543dab8ef62d4e9fb96fb31a30c2b8b14a8763bccf48d428294d6b3041c0/sqlalchemy-2.0.52-cp312-cp312-win_amd64.whl", hash = "sha256:2d5e53e36e37129fe0be8b9d08b6e4052c10a963ee6cda56c8c10dcc194b99ca", size = 2157344, upload-time = "2026-08-11T21:14:52.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/18/e30c6fe1eca1bf34a39fbdd6066121cc9974c850faf6f349eac563697a26/sqlalchemy-2.0.52-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2eb3c6a64b1bfe6704777cfd504e7b8ad093a5f3e03ce67663a5e6742f294e43", size = 2167724, upload-time = "2026-08-11T20:58:12.679Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/2e17d161a4f7ecc1c2ffb93e607b4e1898bb551b451b283235acb8f6ce47/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:923bb183c1dc64fdf7b717965e3d59938ec4f8b8710b419a21ce403e5da9a9e1", size = 3321189, upload-time = "2026-08-11T21:02:41.932Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b8/8490916e893f3f8d74dc9cc54c078619364999dee37047a188e73abbc852/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:651d6d8782e80679e6151707c7b490834d46ada526328895abf567f25e63d29c", size = 3338185, upload-time = "2026-08-11T21:17:02.597Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f7/752cc8ee453da222829b3f5c4613614bf750d97429363b70414fa10478e4/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b08cddb8989775e3c88799d86704bdfc3ee6e9846118201aa5997f16f27e3a15", size = 3271698, upload-time = "2026-08-11T21:02:43.963Z" }, + { url = "https://files.pythonhosted.org/packages/51/e6/074ade0c07b9e4c8e8bca46820320ed94df9702afdb6f2af06623068d2e6/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab66fa9618269390d4dfa222f2f2f88f7bc4bf5da13905131b818217db7e8057", size = 3308936, upload-time = "2026-08-11T21:17:04.172Z" }, + { url = "https://files.pythonhosted.org/packages/66/07/557c0d04716705599227945ac14e0a17ad0338e899f37d8c2ddff4dcc663/sqlalchemy-2.0.52-cp313-cp313-win32.whl", hash = "sha256:c63bda077685c85ca513286547a531ba57e7a68cf0a7ed3bafcc2bbd18896f4d", size = 2127308, upload-time = "2026-08-11T21:14:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/96/4e/226eda27654318ce525d043025221f689abef883da2c7126f9065121618c/sqlalchemy-2.0.52-cp313-cp313-win_amd64.whl", hash = "sha256:9876b09b9f1ce7398b0ffece585c0a911244c53191187341f6bcae640e133751", size = 2153876, upload-time = "2026-08-11T21:14:55.527Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f5/71cb30af58c9b80a4e1fac0b73bb48f86d497a774a6a2eb6d2f1e657bb73/sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9", size = 2169537, upload-time = "2026-08-11T20:58:13.855Z" }, + { url = "https://files.pythonhosted.org/packages/4c/93/d07ebd645d1b07b6b5ed63450a70f063a346a7e0f2c8810daf2e532400cb/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e", size = 3319606, upload-time = "2026-08-11T21:02:45.829Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5c/290c84c7c2566ecd3b65baaae0fddec9bc33b033b398a06123bb86fbfc6e/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437", size = 3323642, upload-time = "2026-08-11T21:17:05.675Z" }, + { url = "https://files.pythonhosted.org/packages/13/f5/2cc160590ca49173359557880b92a0572293ccb899e8f6cedf150c5a3ddf/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8", size = 3268125, upload-time = "2026-08-11T21:02:47.649Z" }, + { url = "https://files.pythonhosted.org/packages/35/f3/ea8933fc9f7d1353e9c2ff9965eae687c4cef181120574591ed2fa0633e1/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd", size = 3289516, upload-time = "2026-08-11T21:17:07.267Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/05cf86541c1e1716fca1e4a996954a439cd74501707cda607fb7cb02ef50/sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2", size = 2130249, upload-time = "2026-08-11T21:14:57.273Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/8ac6ffa1e36169e762ef65bd835046abb2251b1bc17f8f6708e14ed8d31f/sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d", size = 2156718, upload-time = "2026-08-11T21:14:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4b/e01a737eef378e734cc6394a82248a6ce13b167dfa36c731075ce9fc9c64/sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582", size = 2190344, upload-time = "2026-08-11T19:53:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700, upload-time = "2026-08-11T20:47:21.603Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/00/b42a44342a054d58cb1115d7c8aa9cb4290dd9442f9c1b91a4b8173dba22/sse_starlette-3.4.8.tar.gz", hash = "sha256:ed89ffbb75cbf78a5fe2f2109cd584792ee7f9dfac96f791db546df8f15f3f9c", size = 32548, upload-time = "2026-08-05T11:19:49.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/3a/764912c58293d95b6dcdf4cc255f9d10de310580ced547b082eb9d72018c/sse_starlette-3.4.8-py3-none-any.whl", hash = "sha256:6e82314c786709a3cd9520f2285cf9fff90e181e598e8a357b0cf80f66afba0d", size = 16516, upload-time = "2026-08-05T11:19:48.748Z" }, +] + +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + +[[package]] +name = "tiktoken" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/62/167a842aa0429d45f5e797354fd4343a96f6043d67d0513c675c7b8d36e6/tiktoken-0.14.0.tar.gz", hash = "sha256:231dec90efcdccf1b565a1416107736f1e09b1a08fe736ef9d6363e626d03874", size = 38898, upload-time = "2026-08-17T19:49:49.514Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/82/d60a7a5d7bff7b4641d556ea68ea5914ea6edc3774a12eb1c0d444701382/tiktoken-0.14.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3b12e54f8bec91433e41aff65d8d1f209a4f678081163747079806e5361f6c91", size = 1095817, upload-time = "2026-08-17T19:48:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/d39ae33d3dc30a0c229ff0cb683df961ebb5e7b8691feb2d08b3ee6ac327/tiktoken-0.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:94f77b60a8ab23580db19ae822744c9716c1720020d2179ca5605112d12326f1", size = 1043064, upload-time = "2026-08-17T19:48:33.138Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e9/8e18cbee0c3ae8321c7e9696bef6090a24eed99a4a75a4c4a7f5115e5a2f/tiktoken-0.14.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:f3d6cf93fbe2e7117eb7bedca684216fbe328a41f0843ce34245451d8eb2df1c", size = 1190381, upload-time = "2026-08-17T19:48:34.386Z" }, + { url = "https://files.pythonhosted.org/packages/af/c8/051e7b72a816ff50eb34a1c7c5b185cd2429ffdf59a497baea35b2b6b2dd/tiktoken-0.14.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:18a1b651c4b032004bf7b4f1713391a54b2a341a52c6e8a2b59acae9d16e13c7", size = 1206869, upload-time = "2026-08-17T19:48:35.581Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b3/7795db206adb6a57d6137fe48ef2cca6b9707e90b86ee8244671592ddc33/tiktoken-0.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4d8d91d68353bd167fdf26467e5ff9e56aaa5f87d6410c0238608629e4dc0d33", size = 1255197, upload-time = "2026-08-17T19:48:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/c8/39/5234783af6b81af645ccdf9438f2f02af472f14e91d876ca2079af641841/tiktoken-0.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10f31e63e40313f2e518d87f7086cfa44e45f64cc14d8ae14103b41220c30a14", size = 1319329, upload-time = "2026-08-17T19:48:37.944Z" }, + { url = "https://files.pythonhosted.org/packages/88/cf/f2d955c8c5c6c67cc86ba6fb132c47c710465ebe6a6dcec1c3b6e250660e/tiktoken-0.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:c6cb9896a82b9ee44e15ba0b5c8044072f2e4d48acaa704c8d3feeef5ad9487c", size = 944146, upload-time = "2026-08-17T19:48:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/8f/c5/9d848b7f408241171e1f843deb8bfa626086452bc9c78beee500829583e3/tiktoken-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c2edf09b381fafbc014ae8e018ed25087abb9a3dafa8465a0ea63c6558c47a79", size = 1094971, upload-time = "2026-08-17T19:48:40.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a9/d94302340304328961d6f0c35ca4e60617fbb57a5cf667e2ed1692cb9e57/tiktoken-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cd8ca1305c1c902fe42c486165f2e4808d9997625c98ffb05b9e0366d99d3948", size = 1042916, upload-time = "2026-08-17T19:48:41.541Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b6/31da98ee871383509cae2ba96a9ddef1965e3c4f8cb6dc7bcda3379398db/tiktoken-0.14.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:1f83081065ee5833d35b49e9180f3d8d15622a603dd1c435da0da6cc12b3662f", size = 1188650, upload-time = "2026-08-17T19:48:42.729Z" }, + { url = "https://files.pythonhosted.org/packages/24/65/8c5dddd7cb67f6571d154a58d7c6e2f07da54bf84c49b6a1839965b7c35e/tiktoken-0.14.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f5e7665f6624e052e5e7f6a36919ab69279decdc976d7b16b4fa15e1897d0513", size = 1206378, upload-time = "2026-08-17T19:48:44.013Z" }, + { url = "https://files.pythonhosted.org/packages/d1/04/522ec59d30dd9a2f3ab837011cd4fc5d1178dc4a2fa07c9fa4b90af6ba9d/tiktoken-0.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:144a3fc369f92b7d548995217c5d6e84038d3572157a0f6f34080d65291d0f78", size = 1253694, upload-time = "2026-08-17T19:48:45.597Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/9019e272bad188a1c61ecf44f25a9ba2368744644e3ac1f3d6516f3c9e80/tiktoken-0.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:151d37a150c8f3dfc5f4345597b10e101876bd1bd13494e0185af6b508758d2e", size = 1317873, upload-time = "2026-08-17T19:48:46.792Z" }, + { url = "https://files.pythonhosted.org/packages/24/7f/fff1217240343c0c11b5938b98aeae0e3a266cacfac25f86f91cdcd748f0/tiktoken-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:c77d4a3e1deb2707819df92046b89aad1ac81d27e07616b797cbff3f62c037da", size = 944395, upload-time = "2026-08-17T19:48:48.028Z" }, + { url = "https://files.pythonhosted.org/packages/8c/da/e273746b9d24a63c776bc60fba914351573ad9c575b52601eb5e60632564/tiktoken-0.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8e947aefe98ef74cce94923f90e48c98fe34eb1ec0a6bfdfadfc5a96359bfc36", size = 1094408, upload-time = "2026-08-17T19:48:49.269Z" }, + { url = "https://files.pythonhosted.org/packages/69/9f/fe6b1aca23331aa5271df5a4bd07bf68a7059254d47faee1b8272592a777/tiktoken-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d6cebe67765569df3dafac8474e4eccf5c19d24140492567a5e58a11445732a4", size = 1038499, upload-time = "2026-08-17T19:48:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/0b/35/e9f47647c9e163bd1de30fe1a491669b7248cfc67b7404c35c009a701e1a/tiktoken-0.14.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:7db45b98e94adf4173a5cd7422b150999a7ee11ff847783a14f6e1b80cc38cb6", size = 1186355, upload-time = "2026-08-17T19:48:51.93Z" }, + { url = "https://files.pythonhosted.org/packages/51/11/9976ad86980a00cdef05e730a0127a2578a1bc6d11644d8d47246de2eb26/tiktoken-0.14.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7896eea257fe497a2b7134474d909156c6744ce8da35bce88011a960e008aa0d", size = 1204197, upload-time = "2026-08-17T19:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9c/7035b0bcfaa68d1ee4803fc5be5214ad865669b05bd20e7105ae8a18afc6/tiktoken-0.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b950248272f1b303dc32986396e2dccfa10cf6d1e83ec8f0bba1776660305482", size = 1250635, upload-time = "2026-08-17T19:48:54.392Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1d/69cabf18bed7f4366da076735816abce0d4db3fae491ae338a6612128777/tiktoken-0.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3de75343041a1c57333b1e707ac8a9769738241d7d6a55d39e12cf84548337c6", size = 1316085, upload-time = "2026-08-17T19:48:55.525Z" }, + { url = "https://files.pythonhosted.org/packages/bd/bd/a2e884fb1402cba5be08836590320012b2d8ada0e2eef9911a64df4bcd2d/tiktoken-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:087538c080e5ff421abd3a0785ed63c5111d06af98e6cd0d374dbe5969147ca3", size = 941208, upload-time = "2026-08-17T19:48:56.938Z" }, + { url = "https://files.pythonhosted.org/packages/50/53/ee1453623bf65f019328721ccb6587846d2c5b7b82f34e73ca09101f072e/tiktoken-0.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9c5fe393aab56469f04e432ff851216d3def3436cf5f07e442a240164bf500f", size = 1094198, upload-time = "2026-08-17T19:48:57.955Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5f/6448cfe278c3664ba9ec5b5ac08344341f7dc3d42888476e215a14eda2be/tiktoken-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbe2cc3bba939bcdaf103e03df9d5039d33887080b315624be28ec69059e5f94", size = 1038820, upload-time = "2026-08-17T19:48:59.015Z" }, + { url = "https://files.pythonhosted.org/packages/69/3b/d67eac1bcce9dee3abe23aff5e3ded3116bbebaf67b80a0811c06d3806fc/tiktoken-0.14.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2157f52e4b4d7ac5ecc7457b3716834706e7ef9a46f5144029bfeb7cf71f4e06", size = 1186175, upload-time = "2026-08-17T19:49:00.068Z" }, + { url = "https://files.pythonhosted.org/packages/37/62/cae690d9783146b0f81f564ada0f8f611de68178c0c9c7e1e969f0516b48/tiktoken-0.14.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:26e60f6a956ee171ab728b37b8439905d7ea1db435c30f9822f291e9861c861d", size = 1203884, upload-time = "2026-08-17T19:49:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1e/633e30237b94e383cf814145499079f3bb9cdd4aeafc1bc42e01b0f810a6/tiktoken-0.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:380873f330b741c4435574f37edb20813d04603ace2d53e0a63560e1fec83010", size = 1250980, upload-time = "2026-08-17T19:49:02.274Z" }, + { url = "https://files.pythonhosted.org/packages/cb/56/4c12f07b812f84206f38d723eb1ebfdd34bad9309b5dbc0bee6bbcff4cbf/tiktoken-0.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3fd7c14b1cb45b486c39fc9b3443bb341f3e2fc7e6f31247f3435a5836651632", size = 1315434, upload-time = "2026-08-17T19:49:03.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e0/c65603f0c44811def666d3fbf611bf2af3b5e1ef613e06c19411419830b3/tiktoken-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:90a762670c7f968184723769a06ed51f5cf5ce5dcd1e30164f25c72d85c2d1f1", size = 940883, upload-time = "2026-08-17T19:49:04.583Z" }, + { url = "https://files.pythonhosted.org/packages/59/b0/1cf129f4af8fc513931f931023def596b7c4bfc77026513cd9d851da9e88/tiktoken-0.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e067f4cbcc5d036e8aff7fe7a6b530a8f4de2e4616ad9005a24a1879e24e6450", size = 1096273, upload-time = "2026-08-17T19:49:05.807Z" }, + { url = "https://files.pythonhosted.org/packages/62/85/2ae74575e321148484147e10b53c3b1717c59ebaa9edb4fe18b1f5c055f8/tiktoken-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f2af4a336ea56d6c14f27741a0e1d8294a35dd0b038bcf990d232ebb54eb994b", size = 1040269, upload-time = "2026-08-17T19:49:06.943Z" }, + { url = "https://files.pythonhosted.org/packages/89/29/92a1120a12e4bcf2d5464350d1a91b68a433d63ce656bb7f806c27aec09c/tiktoken-0.14.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:f702e0aeeb6506e57687e881c59e844ebe8f0a6a097ddafe20e3ab25f387be4e", size = 1186101, upload-time = "2026-08-17T19:49:08.102Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7d/144af98dc5ad68108451a82e2f5a17f80e2663f5115058b8dfd215c1ad02/tiktoken-0.14.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e3442bbb2f0c588cec876061e37ae67b455b9df9978b003c8fe30e45f2ef5b42", size = 1204457, upload-time = "2026-08-17T19:49:09.28Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1f/be7cb06ab2108f612f3e92e7b76cf391e192db0db37a984616f0cc32aafc/tiktoken-0.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:979c1524f753b662b0f3cd261b135afe6659cce33caaa7a5ea00dd1756b3055c", size = 1251716, upload-time = "2026-08-17T19:49:10.509Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6b/81f158d0f90adb826cd704069c2129a046cb784a2a09861009519fc41cf4/tiktoken-0.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2cc19ac87b41c9493c9778ff5847f0c8bbcf5bd0ec6b87ce06c1c802adc8a771", size = 1315432, upload-time = "2026-08-17T19:49:11.844Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ec/f5fa35ec13f07279fdcaf3cc9c04bbb154ea591d23978651f2b672593e8a/tiktoken-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:eceeff0c62419bc78d4b6e70a4762a4d25df3ae8f2d5946e3853ce93e7a57098", size = 988046, upload-time = "2026-08-17T19:49:13.282Z" }, + { url = "https://files.pythonhosted.org/packages/68/c9/7756717408d3d0dfea3f046c9466144b28afde39ff69d5808f2475dcd7f5/tiktoken-0.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6eb94895c45f26bb8f5546e5fd8a069efcf6e3f108ea9d5cbe3bf6f7f3983438", size = 1096261, upload-time = "2026-08-17T19:49:14.351Z" }, + { url = "https://files.pythonhosted.org/packages/79/29/46ad8061f57bd9f8b2ea0aa82bf574e0f2aa040b0857a1582adba9957899/tiktoken-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:86951a971c53979ec857bd8c4a32dc227ab0fd33f6c12a3bd62d3fbf5f0bfcaa", size = 1040183, upload-time = "2026-08-17T19:49:15.707Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7c/3184d17b868456f17b60b1a75f5ec0405618a43aa753336df341d8f11781/tiktoken-0.14.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:e2eca764c53490f8930dbce329e0769f11108d87d908282a80c5c130e26e7037", size = 1186719, upload-time = "2026-08-17T19:49:16.84Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e8/46de4400d5bf859f640feee85bd7e32235f68ddf25db53c63be78e581e3a/tiktoken-0.14.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:26cc4b4840fa0e9f4b72ed489883e12f57e00d1021ca794720e3c29a12f0edef", size = 1204660, upload-time = "2026-08-17T19:49:17.987Z" }, + { url = "https://files.pythonhosted.org/packages/29/ce/af8964c38bc8226dd8950305b7a255fa33345d5572f78af7275a313d28e0/tiktoken-0.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2fc834fbe3f6a0736905c36ab709537e6840dbd63b982dc9e0216ae7d305ba1a", size = 1250932, upload-time = "2026-08-17T19:49:19.28Z" }, + { url = "https://files.pythonhosted.org/packages/1d/4b/323631116fc986d9cc5bbeb2b8223c7c85e61a8bb94ea5ab4951023b149b/tiktoken-0.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ca4db6ff5c5bf600f9b7761a0070ed44dfe5797a76bd432fb978bc480ef40c58", size = 1315190, upload-time = "2026-08-17T19:49:20.467Z" }, + { url = "https://files.pythonhosted.org/packages/18/8b/ba48a73729c9270989b36f37ab2ed5525e52690d715097c9fa791aaa5d05/tiktoken-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7aab286a020660a039097912a088236b985d18a3090d73f136c4413d29d37ca0", size = 987717, upload-time = "2026-08-17T19:49:21.704Z" }, + { url = "https://files.pythonhosted.org/packages/1d/10/b73b7e319179e0f60b32475f783b044f9cece872c53b6662664e9084b0d0/tiktoken-0.14.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:14b47e3674f2624803a8acc8fb367b7e24fc53055f9df3296482fe9a3a34a232", size = 1096280, upload-time = "2026-08-17T19:49:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/c2/6b/09999a9bf1d559670d1680e8f8e419ac0e2c5f6aac82e9bfdf70f260b30a/tiktoken-0.14.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:19d643d701fdaa70e5b9c7f8f96abcaffe77ca5e482a3a1a7dde46feb4284695", size = 1040433, upload-time = "2026-08-17T19:49:23.998Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7b/8537be0836f3df99b2a636b44399bfa43cd757f2b8b4097dacb794cf24a7/tiktoken-0.14.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:e4ddf863b59347deaa92302dcd90e5eb003cdc9be06ec2b692c38d1bdd9efd49", size = 1186989, upload-time = "2026-08-17T19:49:25.021Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9d/f9c56d7a943a4468abf9ef37661bb9b8e0cd3aa8aa87368c7146cc3f3222/tiktoken-0.14.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:60c47ca69ddda0dea8256fffd12e1b86f4b59734a20e4a70c61f63cc5f021df4", size = 1204615, upload-time = "2026-08-17T19:49:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d2/98a38579db25c4a8a84e31dd95d9072ec5f21f7e70de591da0412e29b25b/tiktoken-0.14.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:728303a072163130c5b477b1f20d6211895569c1d5302c24ffc93a3009160871", size = 1251828, upload-time = "2026-08-17T19:49:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/0c/83/467be424746c039c5493c0f4102feab16b9b48eb6f5c089b2a2438e3cde2/tiktoken-0.14.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3c5349c9f916283bba32bec8af69b763e4faa304dc004d0eaaea66a3cf004c1f", size = 1316260, upload-time = "2026-08-17T19:49:29.101Z" }, + { url = "https://files.pythonhosted.org/packages/02/ee/ddf46ca78e371f5890e96b6e7d089a85b3536432be219851eb0481786ca8/tiktoken-0.14.0-cp315-cp315-win_amd64.whl", hash = "sha256:1b6e4adcfd285c44502aed51df98aaaca4f0fea028165dbf8a9e857b9f98d8ea", size = 988230, upload-time = "2026-08-17T19:49:30.246Z" }, + { url = "https://files.pythonhosted.org/packages/2a/00/5162e90c851a28da18ed382d34898b79a8022548e5619a64e14c03ce7c3d/tiktoken-0.14.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:11d8211b290855d2721334ff17dd9b3a17bfb26872be01f25d73612ef7ece890", size = 1096186, upload-time = "2026-08-17T19:49:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/65/97/a5a7bfccf25b1bb65e82bae8edff11ac3c9c041c374b7b4a823d60c38133/tiktoken-0.14.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d0781223705199b289faa59601bb9c2441712d4c600dd13c43d8fd6a33d22cd5", size = 1039947, upload-time = "2026-08-17T19:49:32.848Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ba/ef427fc638f1439181c5e12dd26b70e881861f89c007aa7e5b36300f8342/tiktoken-0.14.0-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:2ea70afba6b9eddbf22c165142e5f0a2ad7aa36a452873c48b57bb2aeb8492ae", size = 1186997, upload-time = "2026-08-17T19:49:34.121Z" }, + { url = "https://files.pythonhosted.org/packages/3e/88/2f3f85a968cdc514152129af0a060ebcccb067005a2f29b0d5ef3c838514/tiktoken-0.14.0-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:78571efc311c30b73f31eb949a921d6dac39a5d9dc42d1cfa8f8db157b3447b1", size = 1205211, upload-time = "2026-08-17T19:49:35.284Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f6/80760e98a08e6649d2d68afb6035af713121dfb615acce8c4f73810ec438/tiktoken-0.14.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86f66c85e796f5d05d5c4a60ec1d40cbfebc47a32464053528c797163fa9ab89", size = 1251479, upload-time = "2026-08-17T19:49:36.419Z" }, + { url = "https://files.pythonhosted.org/packages/c5/84/50966fb6918a0fb9b32721277e5342bf729a2d74350074d662fbedf9772e/tiktoken-0.14.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:149d97453c4c98c04b081d64a85e635921269b532710d6faf81e9e82b790e7d3", size = 1316673, upload-time = "2026-08-17T19:49:37.756Z" }, + { url = "https://files.pythonhosted.org/packages/35/5e/9b01afd037bfa22a0033963fa091e0f75b6fb15cd85bffb42ff86e697323/tiktoken-0.14.0-cp315-cp315t-win_amd64.whl", hash = "sha256:561e7580f84a79859af1ef6f676968e9030fcc3fe195700b15235bca64f009c9", size = 987929, upload-time = "2026-08-17T19:49:38.947Z" }, +] + +[[package]] +name = "tinytag" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/a4/a1d39cc10b43cbbae268127a1c38d689bc6a85cf966f9445bc9f1f5f517a/tinytag-2.3.0.tar.gz", hash = "sha256:84850f8045424b944475b9754bc35c7e09bcae1ab08d1f88d9293aa33af39a27", size = 44379, upload-time = "2026-07-30T23:35:03.407Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/48/f9a955e0d27376dd0a6bcf7973818b19e5965dd847580aef2f08c6d6f64b/tinytag-2.3.0-py3-none-any.whl", hash = "sha256:231ba5b2fb7a6db478f6dd344ebf20dfdfcd5907f142d475605d516dd7e8b9a8", size = 37155, upload-time = "2026-07-30T23:35:02.003Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa", size = 365748, upload-time = "2026-04-27T14:43:25.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/39/b87a87d5bb9470610b80a2d31df42fcffeaf35118b8b97952b2aff598cc7/tokenizers-0.23.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e03d6ffcbe0d56ee9c1ccd070e70a13fa750727c0277e138152acbc0252c2224", size = 3146732, upload-time = "2026-04-27T14:43:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6a/068ed9f6e444c9d7e9d55ce134181325700f3d7f30410721bdc8f848d727/tokenizers-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0948bbb1ac1d7cdfc9fb6d62c596e3b7550036ad60ecd654a66ad273326324e", size = 3054954, upload-time = "2026-04-27T14:43:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/6c/36/e006edf031154cba92b8416057d92c3abe3635e4c4b0aa0b5b9bb39dde70/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bf13402aff9bc533c89cb849ec3b412dc3fbeacc9744840e423d7bf3f7dc0e3", size = 3374081, upload-time = "2026-04-27T14:43:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ef/7735d226f9c7f874a6bee5e3f27fb25ecabdf207d37b8cf45286d0795893/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f836ca703b89ae07919a309f9651f7a88fd5a33d5f718ba5ad0870ec0256bad6", size = 3247641, upload-time = "2026-04-27T14:43:03.856Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d9/24827036f6e21297bfffda0768e58eb6096a4f411e932964a01707857931/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae848657742035523fdf261773630cb819a26995fcd3d9ecae0c1daf6e5a4959", size = 3585624, upload-time = "2026-04-27T14:43:10.664Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/22f3582b3a4f49358293a5206e25317621ee4526bfe9cdaa0f07a12e770e/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53b09e85775d5187941e7bab30e941b4134ab4a7dd8c68e783d231fb7ca27c51", size = 3844062, upload-time = "2026-04-27T14:43:05.643Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/b8f8814eef95800f20721384136d9a1d22241d50b2874357cb70542c392f/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea5a0ce170074329faaa8ea3f6400ecde604b6678192688533af80980daae71a", size = 3460098, upload-time = "2026-04-27T14:43:08.854Z" }, + { url = "https://files.pythonhosted.org/packages/0d/d5/1353e5f677ec27c2494fb6a6725e82d56c985f53e90ec511369e7e4f02c6/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4", size = 3346235, upload-time = "2026-04-27T14:43:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/39b6b8fc073fb6d413d0147aa333dc7eff7be65639ac9d19930a0b21bf33/tokenizers-0.23.1-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:56f3a77de629917652f876294dc9fe6bad4a0c43bc229dc72e59bb23a0f4729a", size = 3426398, upload-time = "2026-04-27T14:43:07.264Z" }, + { url = "https://files.pythonhosted.org/packages/0f/80/127c854da64827e5b79264ce524993a90dddcb320e5cd42412c5c02f9e8a/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d10a6d957ef01896dc274e890eee27d41bd0e74ef31e60616f0fc311345184e", size = 9823279, upload-time = "2026-04-27T14:43:17.222Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ba/44c2502feb1a058f096ddfb4e0996ef3225a01a388e1a9b094e91689fe93/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1974288a609c343774f1b897c8b482c791ab17b75ab5c8c2b1737565c1d82288", size = 9644986, upload-time = "2026-04-27T14:43:19.45Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c1/464019a9fb059870bfe4eebb4ba12208f3042035e258bf5e782906bd3847/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:120468fb4c24faf0543c835a4fabafa4deb3f20a035c9b6e83d0b553a97615d4", size = 9976181, upload-time = "2026-04-27T14:43:21.463Z" }, + { url = "https://files.pythonhosted.org/packages/79/94/3ac1432bda31626071e9b6a12709b97ae05131c804b94c8f3ac622c5da32/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3d8f40ea6268047de7046906326abed5134f27d4e8447b23763afe5808c8a96", size = 10113853, upload-time = "2026-04-27T14:43:23.617Z" }, + { url = "https://files.pythonhosted.org/packages/6a/dd/631b21433c771b1382535326f0eca80b9c9cee2e64961dd993bc9ac4669e/tokenizers-0.23.1-cp310-abi3-win32.whl", hash = "sha256:93120a930b919416da7cd10a2f606ac9919cc69cacae7980fa2140e277660948", size = 2536263, upload-time = "2026-04-27T14:43:29.888Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/2553f72aaf65a2797d4229e37fa7fbe38ffbf3e32912d31bdd78b3323e59/tokenizers-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7", size = 2798223, upload-time = "2026-04-27T14:43:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2b/2be299bab55fc595e3d38567edb1a87f86e594842968fa9515a07bdcf422/tokenizers-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9", size = 2664127, upload-time = "2026-04-27T14:43:26.949Z" }, +] + +[[package]] +name = "tomli" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/b9/de2a5c0144d7d75a57ff355c0c24054f965b2dc3036456ae03a51ea6264b/tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed", size = 16096, upload-time = "2024-10-02T10:46:13.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/db/ce8eda256fa131af12e0a76d481711abe4681b6923c27efb9a255c9e4594/tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38", size = 13237, upload-time = "2024-10-02T10:46:11.806Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/19/b65f1a088ee23e37cdea415b357843eca8b1422a7b11a9eee6e35d4ec273/tomli_w-1.1.0.tar.gz", hash = "sha256:49e847a3a304d516a169a601184932ef0f6b61623fe680f836a2aa7128ed0d33", size = 6929, upload-time = "2024-10-08T11:13:29.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ac/ce90573ba446a9bbe65838ded066a805234d159b4446ae9f8ec5bbd36cbd/tomli_w-1.1.0-py3-none-any.whl", hash = "sha256:1403179c78193e3184bfaade390ddbd071cba48a32a2e62ba11aae47490c63f7", size = 6440, upload-time = "2024-10-08T11:13:27.897Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typer" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/40/4a3db7990d1f62a53182aa96eaef57aeb2886a27f90a195bc66713565d31/typer-0.27.1.tar.gz", hash = "sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df", size = 203994, upload-time = "2026-08-03T14:41:03.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/89/9518bc0c3929bee36b3a4a8e3daddd6e03f92f9961c66d4983b837160543/typer-0.27.1-py3-none-any.whl", hash = "sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56", size = 122874, upload-time = "2026-08-03T14:41:04.391Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uuid-utils" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/91/63938e0e7e7876658e5e40178e7c0735b53527886fe11797a11699c55edd/uuid_utils-0.17.0.tar.gz", hash = "sha256:abb5667a36119019b3fa320c4d10c21ebccfcc87c8a739e6a0056cee7f48dde2", size = 43220, upload-time = "2026-07-09T13:49:58.433Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/60/659104207938f2ac62508b9aa595fc0515ac7452dd515c8e1d47d0b91169/uuid_utils-0.17.0-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d2d9a63a9e6f2416ace8c109043a9280d6b34f34bb2e5421903e149403db40a6", size = 564038, upload-time = "2026-07-09T13:47:51.731Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e7/e0d048a268b4163058bdd2f07a45bbe13c29e3cc6b7b88f8f00b001617ce/uuid_utils-0.17.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b776c7fc8755c7de06dd5a22b47c40ae84f67d13277ebb233cc84933ba4dcbcd", size = 286680, upload-time = "2026-07-09T13:47:53.141Z" }, + { url = "https://files.pythonhosted.org/packages/84/83/e3606dc9b4224d0c9a6675d9347e7e0da7e67fa30e061bfdb686138844d0/uuid_utils-0.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1edf2f8732e4ed95bd7b65f2658f4aa072efaaff321144f4e0d4bf6a22709263", size = 323533, upload-time = "2026-07-09T13:47:54.433Z" }, + { url = "https://files.pythonhosted.org/packages/22/f8/aec5c34fa80c9fef09a506a098015e728080076494b72b9e8e5cfc9669c4/uuid_utils-0.17.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:84ed3a2d5cd3ae6db87af20bfed3331116195ba4757ad7177fc8f12c1bbce2a9", size = 330691, upload-time = "2026-07-09T13:47:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/08/73/85776566863514f37b0a761648368e96b07d64981a9b6c391220aa2563a9/uuid_utils-0.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bf4d9cd1e80e73922073b9b27c143bedeb109d65f94cd12712e2c87118f2b7d", size = 444094, upload-time = "2026-07-09T13:47:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/06/e0424b4268c0932e0ff8257303d70de4053f05958843268fac4cb0f79b57/uuid_utils-0.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52db0e471d3d2632d35445af352591f40a8f32959a412981d9f51e068bb9514b", size = 324548, upload-time = "2026-07-09T13:47:58.217Z" }, + { url = "https://files.pythonhosted.org/packages/db/d2/a0cb3a69ef6d9becc30a6a0594ddf6f798f6204953dfa85073cbec875b94/uuid_utils-0.17.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:344f7c755e280ea0ba6aeb08022190d867a80000b1715cacded54fc4b5633607", size = 350307, upload-time = "2026-07-09T13:47:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/82/81/d82766af7db541e4a78b920bc1c4303d44995f841805d1498934088cd12c/uuid_utils-0.17.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:589d9da7de8fa7f739bb970ac4632c9a268213117d634e1c4a58c1c1e821ca05", size = 500661, upload-time = "2026-07-09T13:48:00.726Z" }, + { url = "https://files.pythonhosted.org/packages/10/71/b261cd0d38497ed8c2cce0263c5607ec9cd2bbace0f73cb19a6fc2060b6e/uuid_utils-0.17.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cee808b405e9095506f4e4e89924bec7ea77eac3129b6fe36eda04364b3b343b", size = 606577, upload-time = "2026-07-09T13:48:02.539Z" }, + { url = "https://files.pythonhosted.org/packages/3b/63/9e48512bb235e9533adbb25c30fd0c9cef09f6ecefe131ba392b98572b40/uuid_utils-0.17.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:53ce348ef4c6e98c02c19c522af01334fe94476ce9af0db8c4482f9f142ae9c1", size = 567054, upload-time = "2026-07-09T13:48:03.833Z" }, + { url = "https://files.pythonhosted.org/packages/b2/cc/d7bad8799a37ec33fc21b29fcb459d63d9f88aa09056d0c3e58903ba2fb0/uuid_utils-0.17.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9e753e81457241e2200c56a898e268e8fa25796271af0489c608f24d8e631eed", size = 529682, upload-time = "2026-07-09T13:48:05.097Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3b/59b1e07ada8aadd3c046c97fe9814d85e770abb7e8cf68d5d86538bf62e9/uuid_utils-0.17.0-cp310-cp310-win32.whl", hash = "sha256:c589f5023d471ce75dd2cce61acb25ed6347e562041588a1a366808f22d7176c", size = 170595, upload-time = "2026-07-09T13:48:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5c/23a2d0253ada2ee8c497d541d4ef0dd5576c3d2454ec2f9d0b8a06af9304/uuid_utils-0.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:981cc10163988defea96e8d6c507df151eab8f483e7df9ae543d5a41a4be073b", size = 177225, upload-time = "2026-07-09T13:48:07.561Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b2/8f03b61f0aa4afc687855c4f00db35f4d3e58c480cd885abc46f6e41308f/uuid_utils-0.17.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:f9b093cb3b6c9d6233ef45a05cab064d2aa0a8cb3c5777084c9e20fcb77c2371", size = 563901, upload-time = "2026-07-09T13:48:08.961Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cb/88b909ffb9ac11f88d2e6ceabc592ccc660b5830b06dbcbd290ab8981f1f/uuid_utils-0.17.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0bc4c431ccd59c764080ceb43b126043325fe17861b87759d026a0cdd8423bb2", size = 286383, upload-time = "2026-07-09T13:48:10.2Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b8/bc5b64e9898867227c535cd0366c571c580a736748e81329437c1773e442/uuid_utils-0.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c00d182e31034250690f417b9068b78eab423c10d76766664e82d9860c340479", size = 323244, upload-time = "2026-07-09T13:48:11.477Z" }, + { url = "https://files.pythonhosted.org/packages/13/d9/8a17462ce066fbf89670fb737a3f0c93a77816736d2a4d134787e759d8ea/uuid_utils-0.17.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:570db214f6d8507587a8faa968a3fe65e957daeb7bc48b27dc7f69bc3ecdd6f1", size = 330466, upload-time = "2026-07-09T13:48:13.092Z" }, + { url = "https://files.pythonhosted.org/packages/43/37/0c65d0db3bae45183419756d938f1791a82c835fd92bf234eb4f008d2e02/uuid_utils-0.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:351462debd866f1f25e4d4f5c7fac89525b52151f0102a1bdfe94a999b046f5f", size = 443806, upload-time = "2026-07-09T13:48:14.372Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/7e698466d1f5254620b5ee0d711fdd20a0e9c2acd7040740c37193a8f673/uuid_utils-0.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:622cdde768300591ac79bfcd7bb3468e4b191b1105d5dbfe8d87c39d8f63dd46", size = 324261, upload-time = "2026-07-09T13:48:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/5d/48/3a5b242d7f0b8e3ca77dcd7177f3cf73e0280cee32e2349d9796ca27f183/uuid_utils-0.17.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:75d7411e8eb9259764dd60310738540649057cda4509b4af14b36b7f663bfeb0", size = 350657, upload-time = "2026-07-09T13:48:17.273Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/f32ea82a89efed2eafee2f1d925d64687a81e550a9951933fb1b75c95ca6/uuid_utils-0.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1019476b6bdc047216ef7414be5babe0fa5ccfde977c0cac4fd6c75ddec66ff7", size = 500613, upload-time = "2026-07-09T13:48:18.459Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5c/c7b73ec4bbe28db162a4841d352c6eda582801e0dd9fe72f6ad5cc584ee4/uuid_utils-0.17.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:04452640d8b6920c480c16e5afe91ff896d236e0c972830f9247e0898d38c803", size = 606306, upload-time = "2026-07-09T13:48:19.726Z" }, + { url = "https://files.pythonhosted.org/packages/63/95/8a2777204e8691b4961e6aa619001c3e5175aa430ab43da3079142e8d310/uuid_utils-0.17.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:793229621e1ad6cac55f015cfa9f4eff102accbc3da25d607b91c6b0bec167fb", size = 567231, upload-time = "2026-07-09T13:48:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/1a/6f/1d778ca3ed6d2cf35f22088e2de714675416747ab41be510f22c141043a7/uuid_utils-0.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03815cea572c8a693cab5475b9d750cc161470961c7defa27e9286cad62f38f5", size = 529373, upload-time = "2026-07-09T13:48:22.312Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d3/9ad1ab64b3bed0a0237d1db89dc6f5001d6116a82766753da4ac4496f979/uuid_utils-0.17.0-cp311-cp311-win32.whl", hash = "sha256:c4f845166b09acc65c5213a35551a7f81c17fa010ab467229b5813f79d17fe13", size = 169930, upload-time = "2026-07-09T13:48:23.504Z" }, + { url = "https://files.pythonhosted.org/packages/c2/1a/e01417f52eae6e2cb412260bb332b4ee4b37af2982d9c38cff4b68b2e899/uuid_utils-0.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:14dc2f46abb1091260c0d203fcbdf4e045042cc07e49183fd3b255904b95eb70", size = 177242, upload-time = "2026-07-09T13:48:24.723Z" }, + { url = "https://files.pythonhosted.org/packages/35/20/396c27f996add19f8ac31e49cc4570824e51a97719087dabf94694d25bc4/uuid_utils-0.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:29179ffb7b317239b6d6afb100d14c439c728770460718280b9c0a42d2561ec2", size = 177023, upload-time = "2026-07-09T13:48:25.834Z" }, + { url = "https://files.pythonhosted.org/packages/20/80/a7e685968e3cec99d6fe2fb25d0f5726310e1bba356da68c13dfd8b7d140/uuid_utils-0.17.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:9205068badf453d2f0821fd5d340389b4679992d7ff79d4f3e5608996dd1b287", size = 556403, upload-time = "2026-07-09T13:48:27.022Z" }, + { url = "https://files.pythonhosted.org/packages/56/47/3102d93bcb7b0bfe6bede63ff8f221a7f91348e10a37f682773be27c56d9/uuid_utils-0.17.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0fcca4e838af9ac9243b3358d7c14afa4dca286a87781124c272d6c4cad9c968", size = 285608, upload-time = "2026-07-09T13:48:28.769Z" }, + { url = "https://files.pythonhosted.org/packages/55/fb/d59695f0f8db065b93c63316eaafa05a22d75a0486978a33736c52c646d5/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f3729e839209f3457d0d8b6a35a376fdf65577a5aecaf4cc3587d3305759ba6", size = 319926, upload-time = "2026-07-09T13:48:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/5a/03/62fabcd1e990e07a0e220e8d552af45bc16f107fa8e55c2014a706bb1a1e/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3dac0ad0cd9a2818d1775215365a4e8c2f8ada215529dd26f3f8cceeb67a6988", size = 327172, upload-time = "2026-07-09T13:48:31.187Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/a5081391338b459e2f8d8b12581f00f8caa6317fab510e0e85c18c59e938/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e671b2322ef09106ecb1ca0f4c398b134d5e2c1f80d7a4f3336847a3072c0e94", size = 439075, upload-time = "2026-07-09T13:48:32.295Z" }, + { url = "https://files.pythonhosted.org/packages/59/30/91795bd01e17a13661280d4899fbf38fb05e3f38e873f9aaec106ec30aa0/uuid_utils-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb3e5caca8d3a6f72ea4cce024583f989f6f2e9186f98800213fff0176e8bcc", size = 320247, upload-time = "2026-07-09T13:48:33.64Z" }, + { url = "https://files.pythonhosted.org/packages/e5/11/09102b78303e4eb62069d6d88ef9fd661dc523e8f429e1fd67eaa78a6f44/uuid_utils-0.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b72c2002202038666bf647f9a790906214c7c11cd0d6efef77b7d07bef3034a", size = 344738, upload-time = "2026-07-09T13:48:34.786Z" }, + { url = "https://files.pythonhosted.org/packages/74/f9/be95bad6954b60328878c3800258f01a6accd24fd75112d13f023462d53f/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4e2ac1c0b56f2c91b6f158e29ed96b1503223fe8aa6e79b1be1dc55bd8a5131c", size = 496845, upload-time = "2026-07-09T13:48:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/02/8a19a34e0530d987488a068a71576a236f5c8c746630b870b57f71eb24ef/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6c142bd0cb4dba31c10babe00d59f7ef6460f0ef55eaa9c1a9da270684af996a", size = 603233, upload-time = "2026-07-09T13:48:37.512Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a8/b1abab36ff73b0248d82179816467f6d39a2e80fd64329a895ca94f3508e/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e252db239eb41c32248e096e0d170bce5896a4fd3405556362bc3dd83d912206", size = 561401, upload-time = "2026-07-09T13:48:38.977Z" }, + { url = "https://files.pythonhosted.org/packages/61/91/70e7b528b351cc03a9ca43e6116371cdde31bb12bcead7ca2ca1367366cc/uuid_utils-0.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:237722b6581bb5b4eb4cefbcbe5c6e2980a440aabe781fbe50ebf1cb71eee4cc", size = 525314, upload-time = "2026-07-09T13:48:40.599Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/9167e90cf9937d6558f92d022ff3024a69d938a514d9c8faa4080f73b001/uuid_utils-0.17.0-cp312-cp312-win32.whl", hash = "sha256:46a73cacdf512f473a81f65dbf84186e08cfe6e9118fa582b6c6b33a8288a30d", size = 166831, upload-time = "2026-07-09T13:48:41.862Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7d/0b889654d9ee3413f810cf4685e241285f650d98a4103ac9f3c6bcc95f29/uuid_utils-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:e59b60a0a4cb7541480e02090d37dc2df3b72df4c2e776fff64ce3a4e3dd4637", size = 172944, upload-time = "2026-07-09T13:48:42.992Z" }, + { url = "https://files.pythonhosted.org/packages/be/35/8c6e1bf65e4d400352885dadc656ad6d0af96e89231e3f04686bc2197128/uuid_utils-0.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:d561a4c5747a1e6c7fa7c49a0292e78b4e8c456332caa084fc7abad8de828652", size = 172459, upload-time = "2026-07-09T13:48:44.271Z" }, + { url = "https://files.pythonhosted.org/packages/d2/dd/614fb9912157ac0128e6050859ccf06d9f13df9a944a803e8f80f6157e38/uuid_utils-0.17.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d11a7bc1e02da8984d32e6de9e0826c6edac00eac17de270f372bf32f9a0af63", size = 557259, upload-time = "2026-07-09T13:48:45.664Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d072711704de3d21bec08b6c2f36a215200ca1d5e01a390ea1ac434080a0/uuid_utils-0.17.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7a49f47ac26df3e431c56b825c1bae8e6d3d591fdbb7438c227cc9845a7e3d73", size = 286271, upload-time = "2026-07-09T13:48:47.018Z" }, + { url = "https://files.pythonhosted.org/packages/18/6d/8a63e5eb2d5a6ba69a6c2036e305075bd6f5a022e7ea25fc6ce0eb7c51d2/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32df1944808877702ceea398c103881c09a679bb672a215e01c2a84231266bf9", size = 320025, upload-time = "2026-07-09T13:48:48.208Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2d/bdc2caf9719d9090d7c46043242ae6136cba4f7a7ee384992ab905ad9aa1/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:98c88d3edd08e7245562e9815996dbc6f0bd4745e1c76462f24af5ae4e187dd1", size = 327931, upload-time = "2026-07-09T13:48:49.673Z" }, + { url = "https://files.pythonhosted.org/packages/b6/33/9219d09d51ead282b578b2a4e0a515c2cce3ec52076cada8bfb7e35727d5/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a4370089c8b2e42f1db51d76408c7fa8eaa2934bf854d17983d16179c07c098", size = 438537, upload-time = "2026-07-09T13:48:50.842Z" }, + { url = "https://files.pythonhosted.org/packages/d8/79/e8e0f8b3955f2081c116157119d87659937893242eb834aa170da04d660b/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09a55b7a5ae764985cb46467496a1787678d0a1400356157a080ad95b1a36869", size = 320656, upload-time = "2026-07-09T13:48:52.164Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5e/d1ceddc430ff04b6e21704b2030d4438074a2f478b265dab43da957791c1/uuid_utils-0.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:56aa6488b931246fae11924e4bd0e2b32677e63945eecb71c29e3c2ca0dc3131", size = 345310, upload-time = "2026-07-09T13:48:54.076Z" }, + { url = "https://files.pythonhosted.org/packages/d5/62/89438e12f389a843e626b7e37691319a057b3d6b80914609106891faadda/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:309a35f12d99dde19032bc2259cda6431c85eeac0879134dc777cc3087d7e1cb", size = 496771, upload-time = "2026-07-09T13:48:55.365Z" }, + { url = "https://files.pythonhosted.org/packages/87/d2/eedcd99f522d60e238ead03844f0d51743ba84d33044959e230b756bf212/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:21c79b61ff750abcf057163dd764ccb6196cde7a26cda1b31b45cd97769e03b3", size = 603631, upload-time = "2026-07-09T13:48:56.746Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a8/bb1b38aaddd7243b6e562c6694f499bf094800918316192fd8cb2cdc2620/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4134353bfe3026ddab8e886002dc52bc5a0ab04611aabb0eaae23c32e6e57f64", size = 562008, upload-time = "2026-07-09T13:48:58.241Z" }, + { url = "https://files.pythonhosted.org/packages/b4/77/5f7ed930dc105e293845c09e4d5bd84076318a12f45a46783e1af64906d7/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7c89359affecebe2e39e6a116d069b363c936511a9572b308402489a26957d89", size = 525527, upload-time = "2026-07-09T13:48:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/fd/25/1b55697adf6811a6f92cff6340e6b03e31fd6bc51066a5c10698c29b3679/uuid_utils-0.17.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:6a019a31bc4db89a0903a3e4f6b218571f3a6ff0ad4b3d3fe1c8f91a05ff6e3e", size = 97965, upload-time = "2026-07-09T13:49:01.217Z" }, + { url = "https://files.pythonhosted.org/packages/26/bf/cd729343de4684230be8a966bad7bfc2cf10ce3e643b1189a8b5370dbe35/uuid_utils-0.17.0-cp313-cp313-win32.whl", hash = "sha256:b3131a82d0c7611f0aa480a6d36929e001a3f54ba0fc029a8118a5863cce513c", size = 167316, upload-time = "2026-07-09T13:49:02.354Z" }, + { url = "https://files.pythonhosted.org/packages/76/f0/e602ae0a1b139a7826e5189b93d91902564def06d5006324fd2faf82c8fc/uuid_utils-0.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:9e311f908d2f842fca4c7dcebc4f10306b8089b204ef04cf6704b4332c9ff6ff", size = 173630, upload-time = "2026-07-09T13:49:03.529Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/024ebece265b387154115dc4f1d9727174ef82623069f4bec8b7ed7e73f7/uuid_utils-0.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:c351737e2e65497c7200ab4ffb8af97e9f48be6488309abdd265fe08d66ee92f", size = 173214, upload-time = "2026-07-09T13:49:04.836Z" }, + { url = "https://files.pythonhosted.org/packages/56/44/e2fd3fdf356e1b55d2acf1b956b4f3f29ffb215a99c387eba04b1c5fba66/uuid_utils-0.17.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:673d89cc434cc9b97a0b4cf61272f6fca70a81f64eb0afbface2a0d9f77f06cd", size = 562232, upload-time = "2026-07-09T13:49:06.201Z" }, + { url = "https://files.pythonhosted.org/packages/19/28/65e0980d668a6d44e699f59d1acf43d6b5d4893592c115ce7c680bb4dfa1/uuid_utils-0.17.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:387cf7437c94ddec08651a0f1081381299c7075bc48a6251d8922bf39973378a", size = 287858, upload-time = "2026-07-09T13:49:07.45Z" }, + { url = "https://files.pythonhosted.org/packages/8f/8d/5e97bcebc90fb6a10f98af3dc1ba552e04183aba59e2edc0b9cf486dd998/uuid_utils-0.17.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:220b52746d99e11964badac3c0869016e0c24bafb70a7dd5c2c072a6be3da9cc", size = 321587, upload-time = "2026-07-09T13:49:09.489Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d7/88b2a2370cc3d455ba0515fb6f5c8f7ac0c0f55a86801b6e56a432f22c17/uuid_utils-0.17.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0ab4a66e7a035ad6625cfc1fbdb34f5c2d25a80ae1ef4bfee458ea2036333c6d", size = 328964, upload-time = "2026-07-09T13:49:11.292Z" }, + { url = "https://files.pythonhosted.org/packages/bd/0f/181c5da673953dfc0958cb4fb3a4984a9098673ddb05cac68e994bc8511b/uuid_utils-0.17.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5641071337eb11d61a001ea08793bf72216f3241f0a433ed2764804b2a3e3cc7", size = 442909, upload-time = "2026-07-09T13:49:12.644Z" }, + { url = "https://files.pythonhosted.org/packages/ec/38/5c5e665af542884a8fd3c61725c38453239e13940326b5b70f3ef8881a97/uuid_utils-0.17.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9082e709014946b1f6e96ae6ecd93652efca2d2a6a3ab67dbe151c8b4bf193a4", size = 323076, upload-time = "2026-07-09T13:49:13.897Z" }, + { url = "https://files.pythonhosted.org/packages/f5/35/7de97de18cbf226c2a4f2104ad15e56ca4491717c81c0b71795c0c585b4e/uuid_utils-0.17.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1fd6f0e8a162dc0e9255b6aebe3cd175e76c33202f1bf39da9e6294b93db0099", size = 347360, upload-time = "2026-07-09T13:49:15.237Z" }, + { url = "https://files.pythonhosted.org/packages/26/a1/9915d5dd59fdd1957ded5d188c0ea0b9db5a1d84d42c8d8828a7b83b366e/uuid_utils-0.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d63010803d7c368963bbe6f7ec379593e76dd581d7db0f29118d88713c9e0354", size = 499267, upload-time = "2026-07-09T13:49:16.774Z" }, + { url = "https://files.pythonhosted.org/packages/c0/05/88108405262ec850cea0f95733445d6873e5772af3292baabd9ef8457740/uuid_utils-0.17.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a46bedc273b6f58f11dee816ff74999625ef8d007890f411b7a4975bf1c89330", size = 604940, upload-time = "2026-07-09T13:49:18.147Z" }, + { url = "https://files.pythonhosted.org/packages/89/d5/6dbcd300de47cc443cff2656cd5327a385751213dcb2101cfee7388170b2/uuid_utils-0.17.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:405233a5f625b3d995648f4647fa6befa4567cf3f74e1f6b9837e16f7310f0e0", size = 564172, upload-time = "2026-07-09T13:49:19.593Z" }, + { url = "https://files.pythonhosted.org/packages/ab/94/e8057f2288a415fba8a978bca4b589f5cb6b91a028a5dc07a1775938b33f/uuid_utils-0.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b6c5d2d71e1f17329150ad9427d27f4a3f29a01792e7ecdc64a98ac5368fc4d5", size = 528533, upload-time = "2026-07-09T13:49:21.075Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6b/31713148c77e48e62f51aa042a98a54a8be0396912ea5130f83f52ae722d/uuid_utils-0.17.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7e9b8728ba07a3cb2f29d5aa1a266c2664eb8ef0fd43afa34627c92f7fac8f0", size = 99197, upload-time = "2026-07-09T13:49:22.351Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f3/ca6f6ac5428312df8ed632f6dd9f9e6aba23090471fcdeae53eab027e8b3/uuid_utils-0.17.0-cp314-cp314-win32.whl", hash = "sha256:58838921e377791ef22c64cc92141bfae030f43651ff9272f0f28a208a9e6a5a", size = 169540, upload-time = "2026-07-09T13:49:23.563Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cd/7ede0db66411fa09817d79b680f7454ea9bee2d374e1922e4efd065760a3/uuid_utils-0.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:42275ebd0e8e74e32cdbfb8bd88fc99576567d51d54a508020611fd8f4f463a0", size = 175984, upload-time = "2026-07-09T13:49:24.703Z" }, + { url = "https://files.pythonhosted.org/packages/f0/81/533b5f80cd4918c0693f4e1b7b90ceb1caa45f4266ae8b528135d7ecca5d/uuid_utils-0.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:b5d11cccba076a32321ef1380dea956821f0b51794ef59df64e58fb1cd543aae", size = 174749, upload-time = "2026-07-09T13:49:25.886Z" }, + { url = "https://files.pythonhosted.org/packages/a0/13/f400ac39d06fd8be5b099c09e41bb975205926722a3e8d53348817cb7ff9/uuid_utils-0.17.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:fae8b282f0cb22a5de222999f7723f4e5ec04f6fcdf4aaef879b5b36625ae2b0", size = 562610, upload-time = "2026-07-09T13:49:27.374Z" }, + { url = "https://files.pythonhosted.org/packages/03/8c/c71c8312304c56f6d0bcba87cd402fa79bec35d18ffc8c41954196ca68e5/uuid_utils-0.17.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:967955620df45e6cffe2e9950cb9903cb455649396f896b26b04363a91a5054b", size = 289473, upload-time = "2026-07-09T13:49:28.989Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cd/522117e2e5184ca1d4f0f85ee833e9e21bd8c6b99eff8a4d1a8e5a194e33/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:375cde148430d60a4a07c03abaa0774c4fddfdd90de99b4ba02f24088bc9d750", size = 321600, upload-time = "2026-07-09T13:49:30.4Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f4/0d81f9bd346fc717bc561c08fa6457e0328966eb76e536b938fe77d56459/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:975c17da26c5b9d46c336b03c52a057ac28378d6f9d98b58d32a038589bb3912", size = 329569, upload-time = "2026-07-09T13:49:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/5e/41/26e1363f36a94c9e8ec2dd21d5f63088d3e7c723adbb12dcc8fdc77be417/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3150d836290c88f1d26eb59c4db280d87417dd3bfaadd2889c77416c8f0ff6fa", size = 442051, upload-time = "2026-07-09T13:49:33.024Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a7/2c1ed1b34d7df7fdcc11c28fd26d94d44843b37d9af2435ff9fd8abdbc08/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9472a8de37faf8bd216c628e0e68c8f6bef730d3ba0a5060f3b0fa460c992ac2", size = 324372, upload-time = "2026-07-09T13:49:34.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/bf/328d3c6bb22c496944a1b3b732207d71aa6964eb604e5e3b9dcb91ed0a00/uuid_utils-0.17.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d27c531edb8d1f38ca2eddaa1fa24913a460aeb721f2efd4ef42a124ce94e354", size = 348548, upload-time = "2026-07-09T13:49:35.898Z" }, + { url = "https://files.pythonhosted.org/packages/3e/76/a07de5cb7b90582fdbbc830fd19be129cbbb9897cfe239fef469d7bd2d09/uuid_utils-0.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5670c52a438e21483ce715776144914a4e2a2a5c62d9dee15f8a3e90cf128ae6", size = 498985, upload-time = "2026-07-09T13:49:37.142Z" }, + { url = "https://files.pythonhosted.org/packages/f4/62/9966e46ae34fcec6b06119631fb3c09705ea78835035ce3a82d3348eb61a/uuid_utils-0.17.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:6f29689a76fe7a49cbd629a794d0ec1eab48814e323a00a146a741b0195bde68", size = 605183, upload-time = "2026-07-09T13:49:38.648Z" }, + { url = "https://files.pythonhosted.org/packages/d7/4e/bb962ba0fe31e903b199f22cf4c1a6cba35a8987aef526d287277ab8ca8b/uuid_utils-0.17.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4441600447d340ae103a353f01dbcd22ff680e5ee1a22988efe8d7b791d8fdb3", size = 565412, upload-time = "2026-07-09T13:49:40.115Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/122adfeeeae8a84ccfd43bce627b104d12a2180a93bffd2c0e1b54dad7a6/uuid_utils-0.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b04935a79c03c41ad08d0a5f390aac968bfb561f1268897bc5b0f077971efd", size = 529885, upload-time = "2026-07-09T13:49:41.513Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/257304dded339dc35fc9bf35722ac68fd4fdb930f255b8f7bccdf74ebba9/uuid_utils-0.17.0-cp314-cp314t-win32.whl", hash = "sha256:239d8a281fe10bae33205b5d43185834d556b18434e0a113b5dc1dfb2fd97e91", size = 169472, upload-time = "2026-07-09T13:49:42.871Z" }, + { url = "https://files.pythonhosted.org/packages/35/c8/e78c06db7e9ce317ce7b8759ff2058333eac75caa8c22b75f0059589c9be/uuid_utils-0.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e288a06cbbbcd01b44386e767985c9e21d2ad9bf59829aa7058d9a2a494804ab", size = 176271, upload-time = "2026-07-09T13:49:44.105Z" }, + { url = "https://files.pythonhosted.org/packages/a7/11/bd1c70e1ad3301163cebe66c8d26de26e6814d52f642a849448bd2833626/uuid_utils-0.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1776a80d16369999b21627028cc5dbce819be83e1e079fdd7a51b587d2916db9", size = 175004, upload-time = "2026-07-09T13:49:45.591Z" }, + { url = "https://files.pythonhosted.org/packages/ee/14/4ae708968b15cac7b68d5b854bfce724b21faa1c7a5147fb96d87f468a45/uuid_utils-0.17.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7b9044ce4acbf392d4b3a503fe377641f4deff82e6c341c36ef27af0dea76cdf", size = 567823, upload-time = "2026-07-09T13:49:46.902Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e2/d3af9c3d1dc6efb9ee1cffab30f3f2aacacc3892b21b495d78d34c6696bc/uuid_utils-0.17.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9a91c4814c7150a4d798da691b7804eacd78c4b84fb392a60fa0de21341861eb", size = 288763, upload-time = "2026-07-09T13:49:48.491Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c2/f1b183e412387529893015a94a8447633c665f6d0392de20e245680e636a/uuid_utils-0.17.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dd4a21baaac9a88486f0dd166c5793feb101a0bb9f006f2c401657fff5a1343", size = 324919, upload-time = "2026-07-09T13:49:49.972Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3c/d32c799bdd51f3b08b6ee95f9de921b59c69075a96767f937fab55014813/uuid_utils-0.17.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32abaafc8e91928b3d9f4d82e42d2094041e38ad6bb964066faadff28e4162f1", size = 332689, upload-time = "2026-07-09T13:49:51.402Z" }, + { url = "https://files.pythonhosted.org/packages/6f/90/b4cd455619ff276dc3c3262a7420ead63aa1e531362f00df4cdb07d90e0a/uuid_utils-0.17.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd741c73440b328f937dc53b344ecadc46bc4f0cec0333a8f42b55f3468ce7ec", size = 445726, upload-time = "2026-07-09T13:49:52.757Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f1/5cc042a37932aa9a66eb8ab4a9a5b31d80261ae4565ff0193d8cc1fb9392/uuid_utils-0.17.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89a0980d49683c00539c59cd9f46b1908c538e6b5b0a48ad12187bb856d0f391", size = 325610, upload-time = "2026-07-09T13:49:54.191Z" }, + { url = "https://files.pythonhosted.org/packages/5e/72/9e800c41d766484484e97845a7a7f677ba94462df86c97183e0290229d16/uuid_utils-0.17.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:de1064663aa7c839286488a319d2b3b478ca5ab5b2091ade888ed0eeca11a98a", size = 352672, upload-time = "2026-07-09T13:49:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8e/86ce2c03a1d9674530f6649e49067f7c69929600127077731de590d12132/uuid_utils-0.17.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2db386941cfdecdd0b5a8ceeed5cf7479c83d1730dcf64a48d43cfa018cc3310", size = 178681, upload-time = "2026-07-09T13:49:57.096Z" }, +] + +[[package]] +name = "uv" +version = "0.11.33" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/2b/0820fbaaeeee0296e93d43c7b53e5dfa6118bda890ca9b7ea9d679b15065/uv-0.11.33.tar.gz", hash = "sha256:a4411bf854f5fe3d4b78d37dd2e4b84e0b99fbb0bd185de1511a8515404e04a8", size = 5802891, upload-time = "2026-07-28T10:24:59.213Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e7/283ae1bfd023d910e1e5a66388723ba41f2dcb60b55172467a21d0a557e1/uv-0.11.33-py3-none-linux_armv6l.whl", hash = "sha256:d629531a4e8f7cd76d861ec2e0035faef2675036a084d0a5b35dad5025ec62aa", size = 21679896, upload-time = "2026-07-28T10:24:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/30/99/559a61e7e891c3074b2cf2ae8481fc7bf2bae7de047e7c9683f3ba0e1b27/uv-0.11.33-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:94fb388a86cf6c2f2610b427f1bf0897bc0f2771a869da41433e17f7d21a1a41", size = 19989546, upload-time = "2026-07-28T10:24:15.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e5/17a4e36299e9bd5e8101680be697c7832afac686d1fe8b28be28046c1d95/uv-0.11.33-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8017991a398a55d177c33ecdb29beb33e7b53969921183e4681e3e5b278d73c2", size = 18324889, upload-time = "2026-07-28T10:24:17.589Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/166e7ce2bdb754e5dcb991773f16b54dc1a2339ac78c2a36414cc69db93f/uv-0.11.33-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:36a98c68ba5c3bb59469414f54aaf00bd3134a647ca34509bc53d133ed8e0b5d", size = 21141300, upload-time = "2026-07-28T10:24:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8e/c95333b21900b3bab223205aaf99eb3415f1fec5203e68fbb55dacf7e818/uv-0.11.33-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:ab9ee61ed9c7c843b52795a96733e109cec831cd3250d73d291bccbb90a81999", size = 21202069, upload-time = "2026-07-28T10:24:23.057Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c9/cf86cd18bf815793ee33878fc9ca9fe5506eb2cb31580c46da272e2aab41/uv-0.11.33-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:923874411bc0bbf5d1de16cdb14a7800d696d6c6e774eb1b202966243c6d507b", size = 21234505, upload-time = "2026-07-28T10:24:25.706Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7b3101d4bb8a70f4ac495e199fe26304357dd94610136083f5e92c408dff/uv-0.11.33-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e00f371e1affe3fac88f9f1afab31139c8199ecf8df7b1a37554f83629fd7eab", size = 22019144, upload-time = "2026-07-28T10:24:28.769Z" }, + { url = "https://files.pythonhosted.org/packages/97/09/1891a906d55dc146e014aa60badc87e74e06c9589ff96505e08a45e033cc/uv-0.11.33-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f4196584a7f7fbfef3dde1b1a0454c174399bd96071cdfb066c263a798a4524e", size = 23159525, upload-time = "2026-07-28T10:24:31.332Z" }, + { url = "https://files.pythonhosted.org/packages/91/84/18fb0f561a59f0e14fb7ce64dad3a6994b37473622e5deebb2434fa59648/uv-0.11.33-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70826563c7617efbf7244946354628253e4ea63d72cb1d35b98be4896c4530fd", size = 22768153, upload-time = "2026-07-28T10:24:34.466Z" }, + { url = "https://files.pythonhosted.org/packages/a3/cb/f0ccaba1bf9b0f778dc883e56f3bfb315e3d974ba36fb7432a067b459528/uv-0.11.33-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9542178978b0b6f16a7ae99e55aca039f493a1edb373a15d7993eab80a28615a", size = 22212804, upload-time = "2026-07-28T10:24:36.997Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/aa413404a2e9731ccf64dff368875d46395957e12eb129d2d2203f184ef1/uv-0.11.33-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:71d00a28beb3924c593ba7ec248263679ae1f7c0f63a8cccc75e7d11ccb2adce", size = 21276985, upload-time = "2026-07-28T10:24:39.468Z" }, + { url = "https://files.pythonhosted.org/packages/f5/fa/71d5502c2fc0f6ce34d36a3e486914af6e05203e2e99c092fa469d2569d8/uv-0.11.33-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:ad74bd02a236417df9766b913c2613a7c3db0ef071c4c2390d28e5005be3e4b2", size = 22023545, upload-time = "2026-07-28T10:24:41.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b0/757180a4ed21f956a93e37de46fe3b4eb0a474e7eea5ddc904f4175d29c4/uv-0.11.33-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:686ed8c8d9e76eb9259e7f7db627ea836420d9a5e013b169c41398bcd28ee948", size = 22143736, upload-time = "2026-07-28T10:24:44.566Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/3f49cbd8631f2abf330d03f5fc687a9c35918c62cbe25ab5921b57dd2c76/uv-0.11.33-py3-none-musllinux_1_1_i686.whl", hash = "sha256:c1ca9b0b51cd89310f87f1fc0bf9d9c6fdb64be516fe3e409817662ec3d9378f", size = 21164825, upload-time = "2026-07-28T10:24:46.992Z" }, + { url = "https://files.pythonhosted.org/packages/f1/74/7170288fa059340bb58ac5574f65d8c689afe3c2baa4099b5fa6cd304b73/uv-0.11.33-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:2a8506f558e08498c0d80857c356452d14048eddfcc7bc20ec9f201b3011eaaf", size = 22412054, upload-time = "2026-07-28T10:24:49.466Z" }, + { url = "https://files.pythonhosted.org/packages/c8/91/86a61485e507d14a14bed223706db4fd6d95c99b452ce881a0122f2b018a/uv-0.11.33-py3-none-win32.whl", hash = "sha256:3242c8bc708b75fb72687c9a24f0e6f47c2004a3d82d32a81b06e17822708c28", size = 19394668, upload-time = "2026-07-28T10:24:51.848Z" }, + { url = "https://files.pythonhosted.org/packages/dd/be/b90df95297cdae2cbd5a83fddf429a304b17a0e9c277ca02190d995cbe09/uv-0.11.33-py3-none-win_amd64.whl", hash = "sha256:521229afa69ad5f57127de800120cb2bea1ac729a05a0851aaf920124f8edf66", size = 20185845, upload-time = "2026-07-28T10:24:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/ed/3a/061c8abd15dbb60a1dce2e28b60378a70fd18152718715cf59a3799f3045/uv-0.11.33-py3-none-win_arm64.whl", hash = "sha256:3cb5b325c58a9ee5febfedf4895e0c48cc7d22d801d1b23a4b8335f16508d0da", size = 19123673, upload-time = "2026-07-28T10:24:57.05Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, +] + +[package.optional-dependencies] +standard = [ + { 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/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { 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.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/5a/2bf22ecb24916983bf1cc0095e7dea2741d14d6553b0d6a2ac8bc96eca93/watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9", size = 400471, upload-time = "2026-05-18T04:31:08.908Z" }, + { url = "https://files.pythonhosted.org/packages/55/70/dea1f6a0e76607841a60fb51af150e70124864673f61704abb62b90cdcc7/watchfiles-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4", size = 394599, upload-time = "2026-05-18T04:30:19.845Z" }, + { url = "https://files.pythonhosted.org/packages/18/52/752dcc7dc817baef5e89518732925795ce52e36a683a9a3c9fb68b21504e/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631", size = 455458, upload-time = "2026-05-18T04:30:29.126Z" }, + { url = "https://files.pythonhosted.org/packages/12/48/366ebbb22fcc504c2f72b45f0b7e72f40a18795cc01752c16066d597b67a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994", size = 460513, upload-time = "2026-05-18T04:31:40.85Z" }, + { url = "https://files.pythonhosted.org/packages/ad/44/1f9e1b15e7a729062e0d0c3d0d7225ea4ab98b2267ef87287153be2495fc/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e", size = 493616, upload-time = "2026-05-18T04:30:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/7e/55/8b1086dcc8a1d6a697a62767bd7ea368e74c61c6fd171683cfe24a3fe5d2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19", size = 573154, upload-time = "2026-05-18T04:30:37.903Z" }, + { url = "https://files.pythonhosted.org/packages/14/7a/242f400cc77fafa7b18d53d19d9cb64fc6a6f61f28c55913bae7c674d92a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8", size = 467046, upload-time = "2026-05-18T04:30:41.869Z" }, + { url = "https://files.pythonhosted.org/packages/02/c8/79eee650c62d2c186598489814468e389b5def0ebe755399ff645b35b1b2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07", size = 457100, upload-time = "2026-05-18T04:31:13.064Z" }, + { url = "https://files.pythonhosted.org/packages/81/36/519f6dbb7a95e4fe7c1513ed25b1520295ef9905a27f1f2226a73892bfb7/watchfiles-1.2.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551", size = 467038, upload-time = "2026-05-18T04:30:32.915Z" }, + { url = "https://files.pythonhosted.org/packages/2f/12/951af6b9f89097e02511122258402cb3578443021930b70cf968d6310dc0/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310", size = 632563, upload-time = "2026-05-18T04:30:11.539Z" }, + { url = "https://files.pythonhosted.org/packages/28/cc/0cba1f0a6117b7ec117271bdc3cb3a5a252005959755a2c09a745e0942cc/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df", size = 660851, upload-time = "2026-05-18T04:31:53.186Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/26347558cc8bf6877845e66b315f644d03c173906aa09e233a3f4fd23928/watchfiles-1.2.0-cp310-cp310-win32.whl", hash = "sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1", size = 277023, upload-time = "2026-05-18T04:30:18.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/68/a5e67b6b68e94f4c1511d61c46c55eba0737583620b6febf194c7b9cc23f/watchfiles-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d", size = 290107, upload-time = "2026-05-18T04:32:09.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "wrapt" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/b0/c1f5a970721f06b85c0cd5142e0ff8fe067708abd779b0c4f4be7d61d09f/wrapt-2.3.0.tar.gz", hash = "sha256:681a2d0eefd721998f90642762b8e75c2159ec531b20ad5e437245ea7b06a107", size = 131509, upload-time = "2026-07-28T06:06:14.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/31/5822ce37ca8820c2ed35a498c67c8b37960b9cee2ba437fd32849d0a234c/wrapt-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0bb2797048db0956348cb3058c33bc4184614f13231389cfbccc16a5d32780a7", size = 81191, upload-time = "2026-07-28T06:04:04.858Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5a/3c6117938be98754578ab83f5a40d7d0ea2cd2c487dc5cd6027ee7228229/wrapt-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce9f398f868d2b3b27aa2ea4de79645ef9077aeeac8dfc2814b0d542c6a2b87f", size = 82255, upload-time = "2026-07-28T06:04:07.151Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0f/94ae724c5087eb6054c0d63febd7094947dcf302fe058e2e0488102a872b/wrapt-2.3.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ad71df7a04dd3497e9302e81f4a7c91bd401ea0e15a9df9029527900f94bee43", size = 155228, upload-time = "2026-07-28T06:04:08.272Z" }, + { url = "https://files.pythonhosted.org/packages/6c/21/1f780bba935dcf697c0c59de9be3a559bbb8e31a53ca3f25422023738432/wrapt-2.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc82c2ccc8e234c844f5303d9f2984b346dcdd53e94823ce8420d2c75b4b9023", size = 157073, upload-time = "2026-07-28T06:04:09.459Z" }, + { url = "https://files.pythonhosted.org/packages/73/31/6c7799d7b6431fcd7e1b83245fb45258a2d2c3a2187fbaecb83572a72d7a/wrapt-2.3.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6e19531ae33c508cea7d84a7edfda01fa86e51b8d1a93a77712c55e6e469152", size = 151594, upload-time = "2026-07-28T06:04:10.784Z" }, + { url = "https://files.pythonhosted.org/packages/ce/17/42d670dbfafd49076c6eb2b7d67633d7e1c968e39bfb11a135acb6fac67b/wrapt-2.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:df4ce31150bcd5d9f36f816aac3010ab4f4bf8672ac1d3b0ac7d539ec61c7c02", size = 156069, upload-time = "2026-07-28T06:04:12.316Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d6/c66b4ba4eda49257c84d5c2df26118280f09ca7905aee20d0064db778d13/wrapt-2.3.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e2e692bc0d63f881cf7006730a56bd4e0c2fab5dc318466942805d692b166276", size = 150930, upload-time = "2026-07-28T06:04:13.482Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f2/1a3b949c0322fb27396eafd1044328c1cb0400e0b32105d75a3cd03096e7/wrapt-2.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c8388ba7faf5dbf9ee106bb70d66f257629b1bd98091123e19e8a4553a319199", size = 154525, upload-time = "2026-07-28T06:04:14.698Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/147563a3dfa6e830c857b93b530ebd8c0cd9d540e5914aec8f9b12880c02/wrapt-2.3.0-cp310-cp310-win32.whl", hash = "sha256:e045ff75d7d94900fc32896ed93c45ce2d2cac28c9dead582ff9a5a49d446e35", size = 77879, upload-time = "2026-07-28T06:04:16.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/eb/921405b4dc55d4f8be4c700ef120539fdd75d5fdb50d83bd257171ee18e0/wrapt-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:b4fc96b159af0a3e0faa72475a69d66292bea72a5bed1e1aca1bffbddc3cb2b0", size = 80733, upload-time = "2026-07-28T06:04:17.43Z" }, + { url = "https://files.pythonhosted.org/packages/b6/13/75947450c5bb57795fa86384721cd52c5c4deb0879022f309501a8a85d44/wrapt-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:1236fa25173ca964c97422470482e9011b9e3c7ed0d75798b40b3da3b0e0e760", size = 80199, upload-time = "2026-07-28T06:04:18.761Z" }, + { url = "https://files.pythonhosted.org/packages/00/b8/9182e4c618a847be0baccb68e4602b070d0fa22c782cf058f4bc66b32709/wrapt-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ab559e1b2551d23d54db2a0001c6d73bad022a254639561c5f6c382a9d6c2fe", size = 81427, upload-time = "2026-07-28T06:04:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/613cefd9c5977366b1587e61c0b428176d382e6d75b454084c5e58503042/wrapt-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bff9a671bc00709cab5a7f745c592b5671873449db0ee2a569af994f16b29a4d", size = 82360, upload-time = "2026-07-28T06:04:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/71/71/4cd2151a236f44a6e2dd4ed8011838d7ba0be3d656c8bafdfc65a2ed1917/wrapt-2.3.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fc648a335d7e01adb3640b25f02fd0ea05886cf04d0af7f4ee902bc7b5e466e8", size = 161700, upload-time = "2026-07-28T06:04:22.723Z" }, + { url = "https://files.pythonhosted.org/packages/49/2c/bc508fee75eb2919ed69769800b09968e4aab16897f909a23f39c81e323f/wrapt-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0077f3d65541925fa83002f967b22ad6550d24813ac64cb905f717194128d9c", size = 162922, upload-time = "2026-07-28T06:04:24.177Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e5/04f34d38e66d857dfc2fc4088d60e70c0e422467822defa49b2b4a26e17b/wrapt-2.3.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9790ea25190a4e0fe4cdf4eeb868e9d75f8a024a70a5b6bf9c348a3a2b72e731", size = 156125, upload-time = "2026-07-28T06:04:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/23/41/c35940ea1c423f129ebe4361db853bc80d4def6326242e1206fa15bf94f4/wrapt-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:816877aa749253149f9ecfd2635d4d948ecfa338e1a0311d187b1acb1bb8a3eb", size = 162039, upload-time = "2026-07-28T06:04:27.154Z" }, + { url = "https://files.pythonhosted.org/packages/0e/60/9bda34c3d7d182aa703fe35339ae0ed4c4dad5e5c587f93890143e1f87fb/wrapt-2.3.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d1c2c1b808600d2ea808e6360910a60ed5f409a4011655e10f9164ba0a414a6", size = 155110, upload-time = "2026-07-28T06:04:28.497Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ba/60bfd9b1a751f4fcb2d603668fc272d651ccdd339a56acf8c40ad21a0293/wrapt-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5ba1e5e08ddc46130e9682b2c249f2d1dd39bda9106ed4bd401b7519f18f41bd", size = 161089, upload-time = "2026-07-28T06:04:29.959Z" }, + { url = "https://files.pythonhosted.org/packages/0f/32/2bd358c6f4f1305c813479d1e9ba746bebdd794f4a20107ab2b3ee0cbd45/wrapt-2.3.0-cp311-cp311-win32.whl", hash = "sha256:45c9279b373d15649dfa2c2077cb3408ea1a6d3125afbdab9d6b809a66f68e14", size = 78030, upload-time = "2026-07-28T06:04:31.241Z" }, + { url = "https://files.pythonhosted.org/packages/4a/62/ecc969b13b141fef89b888c9760821cb01a86ac8fc953911592c8e1e1522/wrapt-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:195b1842b4122fb54e3cd3dd5b2b4aa49302a5a61da901df0481f5c97aedde84", size = 80944, upload-time = "2026-07-28T06:04:32.655Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3d/9278ada8a2b3f24372b630361e84e9a7de7abc3784634860c26d1c37785a/wrapt-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:6db604ef0c67bdb2042ecdfd7b7f037cf09733557ca42360d1018285634f7b98", size = 80074, upload-time = "2026-07-28T06:04:33.811Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4a/d17a0fad1bf1c5f2c887ff71fef75654141b0880bff71d157d955b5bec3a/wrapt-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0a45ffae742ce91a16e11cb6c7cd71e7f9994f3cbd283b962ab093f5c6dcf525", size = 82139, upload-time = "2026-07-28T06:04:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/6e/55/51b92daaf6defb57f4dc56bdcce985400f75c6984a03ca5e78ccac717028/wrapt-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:69e477046f2237ef0bc6547544ee73008dc764ca26eff44f09e976d221b34d5d", size = 82723, upload-time = "2026-07-28T06:04:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/28/7f/cfd9bc4b1f5e424eeea83d0493e43f3b1b02707ce8e50c47945873982bd5/wrapt-2.3.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d221a6e6ddd302b8397433184e96b59f259f50024b854db1c411a881586b6b8", size = 172381, upload-time = "2026-07-28T06:04:37.674Z" }, + { url = "https://files.pythonhosted.org/packages/cb/89/ff7814f6eb6856b479946117d1138a2fbb46cdb6b1f379db359056c69743/wrapt-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:392158c9a7f2ab1b8699418bfc0fe6f83548788c418b27d7bf2019ad3405cebb", size = 174120, upload-time = "2026-07-28T06:04:38.987Z" }, + { url = "https://files.pythonhosted.org/packages/12/1e/8eded8615d39e3ce81f626937a3a87b280a2a86239a2bf14a4b4bb345034/wrapt-2.3.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e5301c35cf75655eb33498f2bd6ae8703ca19940e3167dc9cdf740c712a39c60", size = 163035, upload-time = "2026-07-28T06:04:40.361Z" }, + { url = "https://files.pythonhosted.org/packages/35/ea/a0af2d9da62897af2a055484920de05dade30d2ba2c0d65cbdea875d3d8b/wrapt-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:418f54bb09d1762db02c7009b4051149893af3153a87f92d70356703c11eea02", size = 171887, upload-time = "2026-07-28T06:04:41.614Z" }, + { url = "https://files.pythonhosted.org/packages/7e/dd/63cd4c864c65ef4906df64bd2d378f4a62b54f28063f282dfb3bf93caead/wrapt-2.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1598becd30f8f2777d18564064eb4f4dbe1ab0e05a8f09786d0ef505ac782bf3", size = 161113, upload-time = "2026-07-28T06:04:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ee/82f1fc9e431b5c2c5a6d201aa865dbeae3984c311c6d11a185f0c8367cf6/wrapt-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3da470536bf9645143323dd41b32db55c6f4304ad382094c1a1da8a92061e10d", size = 170530, upload-time = "2026-07-28T06:04:44.212Z" }, + { url = "https://files.pythonhosted.org/packages/37/a5/5dc590e863a419930d988f8b7ca3e75a6befcfb10b6003b3a152f3d5f732/wrapt-2.3.0-cp312-cp312-win32.whl", hash = "sha256:fb8e2e6704a1e0b1b989546c69e2688371ef4a07fa5f61bde3eb6211186f5ac1", size = 78323, upload-time = "2026-07-28T06:04:45.484Z" }, + { url = "https://files.pythonhosted.org/packages/51/f9/4a6925a07951df56394f7e6ebe14f69f1c5ef9d87aa63e0839acf15aa63a/wrapt-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:cdc021cb0b62471d6aac7f2bd92f3b4658073775f9ee7fcd325c511129e7bcc8", size = 81180, upload-time = "2026-07-28T06:04:47.021Z" }, + { url = "https://files.pythonhosted.org/packages/a8/4f/8b5de0395b2a72216751d41c9861df6facaeb611b619d8810ed2b3b23eb2/wrapt-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:67bfe2485f50368c3fcd2275fc1fd100e350d601e0058921a7c82678a465aeab", size = 80155, upload-time = "2026-07-28T06:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6e/0f88a072483e76b881e3fdcd6b6ffb4a5791002514fe541e72b1b73c859a/wrapt-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d3fb71e65b001adfc42684522eeccd9c21d8ba679945abc993439567b66e59f", size = 81960, upload-time = "2026-07-28T06:04:49.622Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ff/b7e2776e7c294075eb712cc9ef573d1b818f393006d09787262b8fc871c4/wrapt-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51a7a4181c1295774812271fbcd7c909df372bc25579d4ed9eb875caaf0ae86f", size = 82435, upload-time = "2026-07-28T06:04:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/d8/90/343bb5d0f1f9669bc252a6073f085b4abf862511bd5c9c9eaec754341f1d/wrapt-2.3.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9045917809c63fdf7abe3a2ceaed3d670b8ee4500ddd9291192d30aeb34467c5", size = 170350, upload-time = "2026-07-28T06:04:52.187Z" }, + { url = "https://files.pythonhosted.org/packages/59/f8/13b79a392930bd0dd6b86cbfbfe1c40944110456e1dc6d809e5c46ece904/wrapt-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54ca1d5573f69b5fe1d74f1f65799c68015e82f685efec9fd8cfa40a094c44d0", size = 170022, upload-time = "2026-07-28T06:04:53.599Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/4f1b6918f5290db959d6e0c07f77385d87cede29c39c9cf8f145e9c82954/wrapt-2.3.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:242b60c21e30866e6a2fa606c612b47c553fa60c0eaeeeb7797fb842ac0ce609", size = 161043, upload-time = "2026-07-28T06:04:54.936Z" }, + { url = "https://files.pythonhosted.org/packages/01/e1/45d3cf74414780bdff6d0380467e003f6eb0f028b6c9403db868dbc7209c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3f3d7ec0a51fbfe00d3aef047641ff2c58b25565b4717fc1f90e050be01cba8", size = 168576, upload-time = "2026-07-28T06:04:56.261Z" }, + { url = "https://files.pythonhosted.org/packages/f3/73/2fa58dd97f191c997755e2c6d569a68f0c433db4e4b36099bdd7227b6cac/wrapt-2.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:261f53870cd4fb2bf38f9f972c56c728fd224cb7c65721307de59d9e7e6741ae", size = 159140, upload-time = "2026-07-28T06:04:57.754Z" }, + { url = "https://files.pythonhosted.org/packages/29/a8/08a56e2000a8816d449dcbad8c8b081697acbbd490821ceca0f9d8e8d20c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8159ec0b0cb7608175eb150de94c19e34f4d47ac655f5ca9baf45df6b688ffd3", size = 169263, upload-time = "2026-07-28T06:04:59.161Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d4/354e1725e35a73b2af4fa70a3e024c7a5d1bf1802dfb862dcb668aae0253/wrapt-2.3.0-cp313-cp313-win32.whl", hash = "sha256:10461884b3014fbfc8eb7d09a93c5f246363e6711d9d881f95eb8c27fdef049f", size = 78241, upload-time = "2026-07-28T06:05:00.507Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7e/34c87fa2174848dfee820322aaa318bab08913998ccecc8d2f57b4ad4639/wrapt-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac870cc97b73bb00ac353329e9559a4bebc47c4c86792ed9b23b58c15b6ad838", size = 81113, upload-time = "2026-07-28T06:05:01.839Z" }, + { url = "https://files.pythonhosted.org/packages/11/86/fcc9a530579e008c9478bb565a6cdfbfd33536660f069c8b91a6607c5050/wrapt-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:a65e8db2b4e90c2e7ade931086351c98ef420bf7a94ee08c95ac8a3cbbc43579", size = 80182, upload-time = "2026-07-28T06:05:03.152Z" }, + { url = "https://files.pythonhosted.org/packages/96/50/3864848b95b28ef73e17551fc8dccbff2628a834f52cf26a57f9c419fb83/wrapt-2.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fd1f2f557dd3491fe75905e578f4db967393d40d1a8f468edc4d40ac7f2d5944", size = 83921, upload-time = "2026-07-28T06:05:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4c/3d1921a60c3e8c71c540ff136e6a47a1fbccf7f671e818394889f7871d9c/wrapt-2.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9f5d2aec29dfc76c37e23897dee92766a3fd4f3bff3ae7fc9c6b4bf37d8c1360", size = 84412, upload-time = "2026-07-28T06:05:05.921Z" }, + { url = "https://files.pythonhosted.org/packages/fa/1a/4a796ff7adb26ada6d4b758c94d47a38320b085e7099afc088efbbcdb006/wrapt-2.3.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:646d20d413ffcd1b0a2f700076e2d0252d872dcb7754860a73e45a59ea883614", size = 207168, upload-time = "2026-07-28T06:05:07.256Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3e/d7777776806c579b761bac2f91721dda9f04c7a1b380213c5935cc750ae6/wrapt-2.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:379f670f45b7bb8993edd9f6fc36c6cc65edb81cffa0b504be34acb0303fff0a", size = 214351, upload-time = "2026-07-28T06:05:08.945Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/2d64d394df7bf181955b3bb562bf33c4492fb4be113f53071106d43ad8b5/wrapt-2.3.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6208f302f110295d64b22a7ac96500c791bf492dce4366e622e4912b077c9687", size = 199020, upload-time = "2026-07-28T06:05:10.418Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3d/fb31d3db7d9834d265fb1a27a2adf0ddf51557c67458c97b22439ad6ae3d/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ed635a9ca4f3a5a2b900c10c69e823373bc00ebc114b459383596d3487da3570", size = 209969, upload-time = "2026-07-28T06:05:11.983Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d1/8724b5da582e62070dc9bf4d8bf1972f317297eefd7ba1f2b5c6393ccf6c/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e3b9eaa742ae7a0aaaaad4ca4b69469d757af2d6e6663ef1dadc47adec0aeb41", size = 196324, upload-time = "2026-07-28T06:05:13.557Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/3d9ef411149543016ee6bcf3af707f787cebd946527452b94bf122e9b7b4/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0f7284f88f4833705132d06d3b425a43095c2cbd07c58166aac3ab646ba12a4", size = 202610, upload-time = "2026-07-28T06:05:15.048Z" }, + { url = "https://files.pythonhosted.org/packages/13/9b/4fc042ceb757866dd4a5fc057b3b736f2b360d3703ce9f830d83dc9226e0/wrapt-2.3.0-cp313-cp313t-win32.whl", hash = "sha256:7ebb274aba688b043429eb1500ff8a76ce0cb8ac0812ca3e301f06247b8722b3", size = 79178, upload-time = "2026-07-28T06:05:16.469Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/b94878f8eed809ca042685276bcea9f24e8c2ca7c9653bb80bbb920a68a5/wrapt-2.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c4bded758ad6f03b965830944a2f0bc5b2eb3767fe5a7310134315d1a6610e98", size = 82634, upload-time = "2026-07-28T06:05:18.026Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/663e1de5332a71685a729754312d327d4cada767c36e1c5a2db4c8de49e6/wrapt-2.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:d2cc64539da63e39ffb9c7ede849b6e8ddaaf7b3876b5cfb04efd85a5f3f4eb6", size = 81387, upload-time = "2026-07-28T06:05:19.417Z" }, + { url = "https://files.pythonhosted.org/packages/58/10/b073beaea89bc0d3670a75ff51139430a54b6af7ba7796507730634536dd/wrapt-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea52a0d0f08c584943d5764be0e84efa912c8da23c23e1e285ff2f5641c18fcc", size = 81978, upload-time = "2026-07-28T06:05:21.133Z" }, + { url = "https://files.pythonhosted.org/packages/b3/31/0916d9cebf848ed3f1a0c1888faee421747df77331e4db2bc527a9a85988/wrapt-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd85b0aa88efdb189d6ae2f35f4526943a8f091c38599c9c31478241c819e6a1", size = 82518, upload-time = "2026-07-28T06:05:22.562Z" }, + { url = "https://files.pythonhosted.org/packages/f5/73/31c1bf0f3384062751c2094dadb314916d70aa9b6bfd26d994b4a7b393fa/wrapt-2.3.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:141ed6211286a9660d8d6702de598b43f0934b4f0eda16393f100a80f501d945", size = 170187, upload-time = "2026-07-28T06:05:23.904Z" }, + { url = "https://files.pythonhosted.org/packages/ed/25/fce087d54b79b8905f3c3c9dd5f454bbd8d8acb80b960c4a6aee5b4659b3/wrapt-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e49885a62ec4ee854d1b9e6371fda6afd219917225752abf729a3f36d4df9a5", size = 169288, upload-time = "2026-07-28T06:05:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/0d09e6dddc6b7a7230ac77f50254b5980ab4fcd22976f72f8cc8a0404458/wrapt-2.3.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d6159c9b2fefec02314e1332dbbbfaf960e369dfd26bcf7f8b258b5732065b3", size = 160932, upload-time = "2026-07-28T06:05:27.022Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ca/0913af0d2ec0c43865d32d615f518fea66c13c5c930e489e9b0de248e9a8/wrapt-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24da48596326ef8e448cfa837b454f638713d3531262375f00e5a9681682fc07", size = 169017, upload-time = "2026-07-28T06:05:28.501Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f2/3d1e47ea81b822210f5df1bf942fd90780a75c055243d569b664529dea88/wrapt-2.3.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cd3a2edf0427013736b8127955cec62608c56e53ea47e82812ea32059cda407f", size = 159065, upload-time = "2026-07-28T06:05:30.01Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/ef2066ced8e5fca204e2b361e9708e36555b40949c583d997ea3b590817d/wrapt-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fa0df3bff4e7ce45759f33fd39335fe2f60477bb9ecf7b8aa41e7d07ee36a23", size = 168821, upload-time = "2026-07-28T06:05:31.649Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e1/016104650d4e572fa91506eb396b3dd8efbccc9284fdc1c9479c3d21db28/wrapt-2.3.0-cp314-cp314-win32.whl", hash = "sha256:2935d5454b3f179a29b12cf390ee47246740ba2c3a7545b1b46ba31a5f2a4a0b", size = 78700, upload-time = "2026-07-28T06:05:33.391Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/6fdc20a9f2ca304748b3f0819cbf377d55260562777bf0b615431bc3c181/wrapt-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:cc2cea812e5cb179a796b766747e7d3b21088760d8deb95676d482b8c8e6fa7d", size = 81422, upload-time = "2026-07-28T06:05:34.774Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a4/9cbd53bf05746bea2c392af39cb052427a8ec95cbd494d930733d8f44681/wrapt-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:22cc5c0a717bd4da87018ae0bffd4c19c6fb679d3ff357216ba566ab26c76cab", size = 80639, upload-time = "2026-07-28T06:05:36.228Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/6c5e4a0f66ea0d2b2dd267e8dd05a0014eea56840b3c8595d40b0a5d1f91/wrapt-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a6b5984cd65dd639546f0eb4b8eacf1c31cb2fe9fb5c27bffe240987cdb2cf84", size = 84030, upload-time = "2026-07-28T06:05:37.714Z" }, + { url = "https://files.pythonhosted.org/packages/6a/eb/a1aedf03283bc9cbf8a1783995ddc54e3c5a86878f19002d2c428494f4c5/wrapt-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c88abcf53daef80e01a75c7530e727fa6e2c1888fe83e3dcdba4c96216a1f5c7", size = 84419, upload-time = "2026-07-28T06:05:39.131Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/50d511c0dc5105563849e86daa3e16ac7feef699f79fb05af45ea70107d5/wrapt-2.3.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85de890ff968196e92dd1ae73a9fb8970495e7650a457b1c9ef0ac3dd550bce2", size = 207171, upload-time = "2026-07-28T06:05:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/3f/59/9b538cf7795217e810699d16bc88b96a830d9b5c403eb2ec2db6b5f2ae81/wrapt-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50f416b74d092bb9f41b424e90dd457f365f7ba4b11de62a23679769a21bd85c", size = 214329, upload-time = "2026-07-28T06:05:42.287Z" }, + { url = "https://files.pythonhosted.org/packages/b3/28/9935d62b1499e5c8b3d191e99ba4eb31ca237a0b699142011a837e9dc7ea/wrapt-2.3.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39febbee6d77301d31da6996b152ce52452da7c7ef72aba10c2fa976dff9c295", size = 199079, upload-time = "2026-07-28T06:05:43.958Z" }, + { url = "https://files.pythonhosted.org/packages/2b/01/4446b80fa2ffa47a3449b250d004ba1c1937f07f64a179608fec735df866/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93513bec052c6cd987f9f580c3df068c8bc4ebae6543736be3ca7ec5959cafcd", size = 209992, upload-time = "2026-07-28T06:05:45.677Z" }, + { url = "https://files.pythonhosted.org/packages/d4/07/56f26c9f9979586a021e8148747004aba4498f49458c90b0502969b904e1/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:729126e667da34d251b8ebf8a45ef0c5ddadc21542b3d6e1abf4259ece6508df", size = 196334, upload-time = "2026-07-28T06:05:47.608Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/6d7bcc895b0f28b2250e10908f060687b9165429dcd7f22ddb3d4c031b74/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:626b69db2021aa01671ec7bbc9740e558522bd44c18cf2ce69bf3d666a014109", size = 202644, upload-time = "2026-07-28T06:05:49.183Z" }, + { url = "https://files.pythonhosted.org/packages/cd/25/7860927edba06b758b8852a6f02e832be715563c67a6795d94350bc81099/wrapt-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:629d73378082c00a8173031f9fb30a3ac6abbc894a5bfdfae71fabc60642d501", size = 79685, upload-time = "2026-07-28T06:05:50.976Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0f/270bafe92fde3b069a39bc01e39ee79340895b335640df861d43d2a51885/wrapt-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:42869085687f0aefd57c0f636c3f9354f8ffb321a8ba9cb52d19beb796e561c5", size = 83104, upload-time = "2026-07-28T06:05:52.405Z" }, + { url = "https://files.pythonhosted.org/packages/55/b3/af176d79a8515a8a720eccdad9a96f6e31a30abf2865430c8c42adf2fd13/wrapt-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b1e5aa486e269b00ed35e64771c7d0ab8096cfd2643405ca8cd60ebedc099a51", size = 81774, upload-time = "2026-07-28T06:05:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/00/39/3daf9f47be208606586de4568ba6713db53ebc8fd7a575aea1fe57983b69/wrapt-2.3.0-py3-none-any.whl", hash = "sha256:d8c7ed08477429752b8c44991f40ad7838b18332a160698740a6bfbc10d998a2", size = 61866, upload-time = "2026-07-28T06:06:12.9Z" }, +] + +[[package]] +name = "xxhash" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/a5/1386f35da1475fcaeef42581deae73417c6d2a6a0b2d2e8914de18844dcd/xxhash-4.0.1.tar.gz", hash = "sha256:d55bf4ef10eb09b8b6866790e083d26d087d84caa3cc0946ba87c3ca7ecaf7b7", size = 101513, upload-time = "2026-08-17T08:24:08.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/de/6854b311cb468985de555e49717357ba1de8ffe4b08eda3f4c522a418fda/xxhash-4.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3f68fe400ceec235f3e4a4b02a28c2fd2d283584a193223c921dd4c48f1d0754", size = 38470, upload-time = "2026-08-17T08:20:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/91/2a/7d79952633fccbf9c2a59cc1dfe9d073f9edfceb052ba9bff5075e31408d/xxhash-4.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b1dddc257279417d93c9e59420d49ef90aece90d7a01996db3aade74b0281b1", size = 36229, upload-time = "2026-08-17T08:20:10.259Z" }, + { url = "https://files.pythonhosted.org/packages/70/6a/6a6f4220c39bae1636eb3f8ef217359b8afe268493ee858adf9002130257/xxhash-4.0.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4972332c079d6aad69c4620a68d015a4ecb33141583f70d642cf9edf6a713763", size = 243168, upload-time = "2026-08-17T08:20:07.211Z" }, + { url = "https://files.pythonhosted.org/packages/c8/03/a1d745b324bede7af8a274d889d25b758402629695499a957375cc5fe994/xxhash-4.0.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1b603d0686c99fa0879f104a74e7db58367634c6e50ba827bee9aa095e23205", size = 266061, upload-time = "2026-08-17T08:20:18.253Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/55c7565db23d2d29a2e31ea9dd0001ad3f4729298f42ba32967dde7644ab/xxhash-4.0.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:33fd538191f47071deef6b1f676535e2aa770f1fd150ae4cc75a34c9e930be3d", size = 287357, upload-time = "2026-08-17T08:20:08.901Z" }, + { url = "https://files.pythonhosted.org/packages/72/31/c967c98cbf151df6ae8756fc9821b1f78d1feffa8420ff2ad5645f27f98f/xxhash-4.0.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33e270d302c95ec426dfa0f5a4e16bff2ab8d7b8a46faa4746affb05e684ac77", size = 270683, upload-time = "2026-08-17T08:20:10.943Z" }, + { url = "https://files.pythonhosted.org/packages/70/98/df9f3c14bc2fb2712ed1497349dc7e8c0b0f20a1f353e2e146d83a1adef9/xxhash-4.0.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3fb1d30d4b6d6e2c4a08e5ac6fffdb2b572d2cfcca15a5509cf4e7a1350f955c", size = 497802, upload-time = "2026-08-17T08:20:17.217Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3b/e5d20b4f602d15fc5fc066f601052a7d6f5a401a50d67fac92098a5829a5/xxhash-4.0.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e27dbed5c4ba033919e4b4ed8dc14e029e91d14a93cd9f920d25277c7df6781", size = 249503, upload-time = "2026-08-17T08:20:40.14Z" }, + { url = "https://files.pythonhosted.org/packages/39/98/4cd03fa7ba791657aa555d0655e89a0d66193b1331e4391114b50227f80f/xxhash-4.0.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43bcf2a871f28f16135545415cab3ec43904d4c80425a64598a9e6cebfb2b5ba", size = 333384, upload-time = "2026-08-17T08:20:18.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/67/a0a076dbcecad2af0cceb873ba4f95475f3955fb81d82f9c5e9f97a12854/xxhash-4.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5dc434c946012e6d8a72b10f970ea30755b718251dd7591dbfdabafd3bcb21bc", size = 262463, upload-time = "2026-08-17T08:20:08.719Z" }, + { url = "https://files.pythonhosted.org/packages/48/94/767b68736250813923d74703ef628b0f57ff1c5bf690d7a6082928d1a14d/xxhash-4.0.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:0b1082fd0f089ce9098ed77aad8b777b5d156f8ac601c69cab73811822b8ef07", size = 289405, upload-time = "2026-08-17T08:20:35.493Z" }, + { url = "https://files.pythonhosted.org/packages/ab/61/69d9ec6e85f9933f26b39753b404f97ffd2fc3bfeeb5e26333752595e060/xxhash-4.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd649663ddeafbfd4734eb8abae921dd5baa1242f20bda54e8bc927369ccded4", size = 247989, upload-time = "2026-08-17T08:20:10.759Z" }, + { url = "https://files.pythonhosted.org/packages/a7/66/e6a033879893074bc441faec65587f9ef9cca49ee3cb99ef20c699b014cf/xxhash-4.0.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:97b455de3e8b1b0b1e4594cb61a468992563f03ca264062fbb0a66b393c01d90", size = 267987, upload-time = "2026-08-17T08:20:20.947Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/a99a412b010cf2cc2bd38a0a44043c894ffa80b46c8d4e1522e40d2ef401/xxhash-4.0.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:2194bf96d5f3d4e0cb65deba370ec83dda3edfba42155f9384190ed5e51ea5e2", size = 322541, upload-time = "2026-08-17T08:20:22.675Z" }, + { url = "https://files.pythonhosted.org/packages/0b/12/1a91408e66d1716bd278cf55529d4ba251d90c6b694ea78a09dcbd9d36c6/xxhash-4.0.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:84df5f8da574caadbc0cb1b8866ecc2368cc941f0cd05f677756c802f370dafa", size = 466264, upload-time = "2026-08-17T08:21:12.422Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c8/8abd4c1b90962f794ed9b139767b0a971e5168e8393961ccf046956c0039/xxhash-4.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c09ada495567c9c9a8156c5ebcfb93be7fece0755062d738c972dcbecd0d84b5", size = 246149, upload-time = "2026-08-17T08:20:23.385Z" }, + { url = "https://files.pythonhosted.org/packages/22/3f/e020285d737a712bbfcf01dfdbbec7773c7c8289447a5373540caf2cdf5f/xxhash-4.0.1-cp310-cp310-win32.whl", hash = "sha256:85bdd40cb505a11e0ca04191711266c5fd696ed786ae83849955e457774edc96", size = 34630, upload-time = "2026-08-17T08:20:10.001Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9b/4e8384b299b40664a225ce9deef54c42b3d59f5181a16d4a374ea3503f8f/xxhash-4.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:8ec4777d92fd61a5c8fdeddab894fd65bea301a8092fb5419ec6472aa4d458d7", size = 37058, upload-time = "2026-08-17T08:21:05.846Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e9/d73c870d9dd26f8d6782ecf716b31e836bb6269c888b6bb6e1fcf71eed36/xxhash-4.0.1-cp310-cp310-win_arm64.whl", hash = "sha256:03600a8987849b2bef7be795a60a6052b635c63fa98b718b08ca5ee823691cfc", size = 33289, upload-time = "2026-08-17T08:20:19.707Z" }, + { url = "https://files.pythonhosted.org/packages/0e/58/bc81e25cceab76ce4b400441e3a43312bf3887fedbb2e5f80cc5a7dd7f75/xxhash-4.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8b4477edc03091f51f5309406d230851c23cf4822029e3bf40b8df53093fff1c", size = 38473, upload-time = "2026-08-17T08:20:34.303Z" }, + { url = "https://files.pythonhosted.org/packages/64/8d/d95e810c9a2930906f1fbd0e38f77554abb8e042a190aa9cbb24da39e9a2/xxhash-4.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:04f9a24de11a6647666d5302fd73d6a5224ce50ddc965fb0bb44cee736e6bd7c", size = 36228, upload-time = "2026-08-17T08:20:36.641Z" }, + { url = "https://files.pythonhosted.org/packages/72/be/ebcded2a32ba664a17a1737a91b6baa298bcda6942acdad81af81660b74c/xxhash-4.0.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8c5ce76b94ba49f3be8a8f2611abc6564210702c72ac9e237ca2bebfd17794", size = 253292, upload-time = "2026-08-17T08:21:27.206Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/72d31d787d988d1130253bc34d249c553f02c9387e7dda789b6d5aa7963b/xxhash-4.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4c8842fb19d78b5e8c2a52baf4c8357658cc56c62bc822b86ce0f942f28e286", size = 276545, upload-time = "2026-08-17T08:20:26.726Z" }, + { url = "https://files.pythonhosted.org/packages/39/ae/048f3b1f283a340bef3697fe5a9d4f10de1696a379af9af6b2352c24d82a/xxhash-4.0.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a43418e1a90b4809a9caf64aeb8b0696e3e1f300a323acc1e6ee2f93ae319fcf", size = 296295, upload-time = "2026-08-17T08:20:15.759Z" }, + { url = "https://files.pythonhosted.org/packages/8e/43/e9a593c81445c8e8d669402edb8264144624e7e5e2406df7d33e3c164d90/xxhash-4.0.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3662719007e059abde7eddacf8517142ba076ddc7b30c807260e57d28c3c191", size = 279966, upload-time = "2026-08-17T08:21:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4c/29da2b166955a300521c0abd498ad4481916c3743949b106192b781f58fc/xxhash-4.0.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06d7fbd609503c3be5e65cdb6bb2f040d6a98574404e2e1d5c60815c97fff4aa", size = 509850, upload-time = "2026-08-17T08:20:32.731Z" }, + { url = "https://files.pythonhosted.org/packages/40/ff/e39e1900179ce7f0f23e7000d9fc672471a8d05b6b2dc657cb496c8975d0/xxhash-4.0.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:101aa300de6ceef3d9c77569706330d8921fc45dd82bceed2084f1e9f2557a24", size = 261005, upload-time = "2026-08-17T08:20:40.735Z" }, + { url = "https://files.pythonhosted.org/packages/89/79/76ee26720d13219458f4b2ec0b22f539fe2bcb1f83dc22a24be4cda4e285/xxhash-4.0.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4296fcc790876a8b0f297edc83d3b088457b774d8f67b4636807f8a2ec69a79", size = 339620, upload-time = "2026-08-17T08:20:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c7/56b53252d64ecb2f3a0d283b41033561b1bec9c268095150153b694c2e52/xxhash-4.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:57d7fa8f23908d173001c21a9e82bfc6ad997d1b6c270fb121812b7ed158891c", size = 272558, upload-time = "2026-08-17T08:21:33.883Z" }, + { url = "https://files.pythonhosted.org/packages/fc/76/83101a2f2ad3eb6b4b5d571e0aa394a576e3e23121707d507d80197ac385/xxhash-4.0.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85e402dab0f9acd3604539747c6fcc57dc188a18af6ab07eb8189351cd32466c", size = 300417, upload-time = "2026-08-17T08:20:36.183Z" }, + { url = "https://files.pythonhosted.org/packages/34/5d/5be1166ec4fc4bc896e508dc51189a2dad200b373269a430e214fb152693/xxhash-4.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fb59a0dd61fb2ad481c03fda399d78ce57dab6bb62c2c8fdb446a7ba4754b89a", size = 259286, upload-time = "2026-08-17T08:20:21.51Z" }, + { url = "https://files.pythonhosted.org/packages/77/6c/42f6201f13278787c58a6b3a1cd597b47e8665a7d4f68a3440d001c05a75/xxhash-4.0.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0b20a06454b34f1531fc677c54efe2ecdec691ef9224f7fa919bf2c1363f7ff1", size = 278230, upload-time = "2026-08-17T08:21:47.62Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/3cb7f149a5a469c628604c33bec6d79764698b315700a4bbf1c6fb15622b/xxhash-4.0.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f7db035447a0ac8959aa230c5d36545ecf9f547413eb1711c0ca6f0ba1418925", size = 329846, upload-time = "2026-08-17T08:35:10.922Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ee/934eb1e11f4d0e95f3828825f8da4b6d59e338235d63edc3d929769262d2/xxhash-4.0.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:34ed93e20bfd98d722b902121643791eeb4b1641871e2dc63d0d4c2d93f187df", size = 477285, upload-time = "2026-08-17T08:20:46.297Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0b/77835dcd7aec970db74f5724c94207ada83b3e2f5e1474ab44b9c8fe5ea5/xxhash-4.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f6247f5e23ee94f2557ac9dab738a336f607c6ff476fcf66ca70c3aef5eee15a", size = 257552, upload-time = "2026-08-17T08:20:58.211Z" }, + { url = "https://files.pythonhosted.org/packages/da/6c/a3ce7a1a9c4ec9ad8babba591a996a71c474ff9630c5fb04c8c9d8b995ca/xxhash-4.0.1-cp311-cp311-win32.whl", hash = "sha256:348c8f288dc961d6bbd1985c8152a3ed7a85c95df00e82320f0c5215d922a399", size = 34630, upload-time = "2026-08-17T08:21:53.323Z" }, + { url = "https://files.pythonhosted.org/packages/d9/58/60d2170e8cda0891aab25dcaa74797b420c3c6cde8a5bc8372f17b30c0cf/xxhash-4.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:ac0f291ab6485bd71f33941f9b92771318332a05d505460b41e893a549caadc0", size = 36992, upload-time = "2026-08-17T08:20:41.973Z" }, + { url = "https://files.pythonhosted.org/packages/ac/de/b229a39f9bbbe30cbcf9afaaa8993cb65286715c90a3b058c3769196ae02/xxhash-4.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:72f34834518157a75e7090f328ee7a16c70c804cfc7c694fa069cc888e9fc03e", size = 33289, upload-time = "2026-08-17T08:20:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/26/6c/dc7cffeadd06336cd934947187cd38abb263103bbc552ca0f55fe4ff595a/xxhash-4.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1ee523f51718e41753f04f7102bb4dc55a18d2ea5cbaceef8ec7ca08571bd428", size = 38444, upload-time = "2026-08-17T08:21:54.332Z" }, + { url = "https://files.pythonhosted.org/packages/75/c9/cf736f6db8c3273af18925061572db0d4357818a9ce425f4b5fb0021918e/xxhash-4.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:515a822c73abbf6a0b7c70976d9662be342835c9d78b8dc7c023411f39c35dbc", size = 36195, upload-time = "2026-08-17T08:35:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/da/a2/ca1929354b6851529d0148f7f335b5e2b0281f83bab3e19f0896dc579796/xxhash-4.0.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f5d031f35962e5483a613214e61f09fe24ab523062c3646d592dc16c4a217451", size = 253113, upload-time = "2026-08-17T08:20:52.152Z" }, + { url = "https://files.pythonhosted.org/packages/de/bb/542005206af59518bc8d78a210f1e0172217bc53beb32f64a5b632e72b6b/xxhash-4.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0264844a09b538c894e5eff25313d941deb4dedec2131b98418a71a3c9944e", size = 276525, upload-time = "2026-08-17T08:21:01.886Z" }, + { url = "https://files.pythonhosted.org/packages/1b/df/607cff25dcb0f1d35c3b04493f6ad8471edb03fd4eacbdcc5ceddef1f3e9/xxhash-4.0.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1642907941ee4b75aacc3db688af52ea02ca2305ab22af7ee686ed726b332684", size = 297703, upload-time = "2026-08-17T08:21:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/15/ba/9d2275eea0b9d9c6b02921be23f7588356c60df95c763b25f0e045894d43/xxhash-4.0.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4af350bc3f329970c0e3a59af84a8a30998bf8a9167eb50cd48e59baaa1d7bec", size = 280252, upload-time = "2026-08-17T08:20:47.299Z" }, + { url = "https://files.pythonhosted.org/packages/1d/aa/2299d9f6369e550aef2abb64945e39daa34412725aa46a20d99b74d76f67/xxhash-4.0.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ba782ca3bf1e81492611152b9a0d5264971339e95e34d69de0ac2c926be496d", size = 511041, upload-time = "2026-08-17T08:20:36.771Z" }, + { url = "https://files.pythonhosted.org/packages/83/97/31bd8b8279e6935a0719f6910ced15e9d5a2cd554b253f6027ce1b5a1c2c/xxhash-4.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:237b8f63a2a0fcfb1ffc06e21dad23add44e6d354b2b014364a1d41e419a4dee", size = 261812, upload-time = "2026-08-17T08:22:00.469Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c1/d180a2da23c105d8e0b02d54f9f5841013fc81c233010ec781e31f1aee4c/xxhash-4.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:81507a68ba84c55241fb61cce1469f473a5da4205fc8ef6f698e5948eea8dd88", size = 339878, upload-time = "2026-08-17T08:35:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3d/f584cd3172fe934f0f5a0a3917d0d7ce781f74d794fd43bb72be71c3ef6f/xxhash-4.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f1ea31d61bcd2cd2f3ec4ca80a64187bbd7948f490b63cf0dcbc6e717b4c1e9", size = 272871, upload-time = "2026-08-17T08:20:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/2c7956b2b551682e00b9aebce9ceb0a991a131d65f9850c09f5f9760be2e/xxhash-4.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:06713a5aaf1d0905c5579416c020c02e42b3ceb931e86c7d3b7fb85403dee3f3", size = 301440, upload-time = "2026-08-17T08:21:35.911Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a2/0739f6482184a8026f4b022718f5f815d352059312e80696825433f0a8e7/xxhash-4.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8cda075b10bb3917b002c74a04f9e02b7d13b5bf732571404d51c52b11c7329", size = 260157, upload-time = "2026-08-17T08:22:01.416Z" }, + { url = "https://files.pythonhosted.org/packages/a1/25/b31a7bcf1d7d116842812e54f9b944843b4236ea4fa85634e8259f342212/xxhash-4.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c10b9206753b64aa791b35b201485477525b26fdec5bf86e8364c388a03e2592", size = 278233, upload-time = "2026-08-17T08:21:15.674Z" }, + { url = "https://files.pythonhosted.org/packages/db/e8/5293bae090fc6119dbc5fcf5c4cc0e1536394b52d73b7904d033836c73db/xxhash-4.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f3e1a44af01b6692de0ec6caba5f0bf93ceb36896e02b7fc00952c6ea7ef39e1", size = 330270, upload-time = "2026-08-17T08:20:51.128Z" }, + { url = "https://files.pythonhosted.org/packages/72/9e/e2ab12d40921f3f34c9317637d65e011aeababf8288356ea8d527de2c1d0/xxhash-4.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6fc415b5568bd9accc7187f1729a99707330c0a67a8b9f93c1149ed573ed75d", size = 478555, upload-time = "2026-08-17T08:22:04.183Z" }, + { url = "https://files.pythonhosted.org/packages/6d/32/c6148d39a49efa95f39b4cf0d41ef35a487f3b30f6fb1fc8fe8d8eab577e/xxhash-4.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96d8de55029d42251945531f6aa7590c32b48163c66a43bf29d8657d7446a377", size = 258174, upload-time = "2026-08-17T08:35:21.18Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fb/0b04b68d6c5bc71c7a2c344f1287327b67e607f28fbcfd937697caca64b6/xxhash-4.0.1-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:0163b5d259de23ae9e07b7eabf435ce4704f6f205589a2b154e6af4be985ce1b", size = 20767, upload-time = "2026-08-17T08:21:00.806Z" }, + { url = "https://files.pythonhosted.org/packages/a6/be/476092aba34d1fcd313e1613a3bb3bc692f253d167b54bc90049043b5034/xxhash-4.0.1-cp312-cp312-win32.whl", hash = "sha256:1216f7ba5683f17a89eb7dcb4bc50a0b743dfe1902278d7b3d0786f538118433", size = 34669, upload-time = "2026-08-17T08:21:49.486Z" }, + { url = "https://files.pythonhosted.org/packages/aa/02/f9413d94fae43cec6d1a74c4f12156c6f4a7f5fd50e1d34defebdee3dec9/xxhash-4.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c2d525a3afabcd8e3549d85fc7e111fde6bc302d06a1893fe73adb79823415e", size = 37073, upload-time = "2026-08-17T08:22:04.886Z" }, + { url = "https://files.pythonhosted.org/packages/c1/83/6fe93c1b95acf962bc61a246df09dc2dcce895ccfc1080c9f48d0b652b92/xxhash-4.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:86b2b12bec60c678ed8f5cca0258ad93a8928ebddb6ca7732f0875afe1451d1a", size = 33299, upload-time = "2026-08-17T08:35:12.708Z" }, + { url = "https://files.pythonhosted.org/packages/f3/dd/c707286b527722f776e1fb81dd202c45623355ba1a2972337a2a26075b2b/xxhash-4.0.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:8c9fe122444e129881afd1d4d1c7ac0d3ce2d91b68c2b40173b6025ff1c31f9a", size = 43639, upload-time = "2026-08-17T08:20:54.945Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3b/bb71639a0f95635f61936a6f2653599c4261b645ddddd8d00f9dfe3613e2/xxhash-4.0.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:1f3346c5c287ac3c7f38b20380f55e8768230e7252af59fabcf3b87ab21e4256", size = 40657, upload-time = "2026-08-17T08:22:12.616Z" }, + { url = "https://files.pythonhosted.org/packages/3c/91/76f3f5385faa9886a36f21fcc603f40b4c0c40ce622382f133160c48b4d9/xxhash-4.0.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:4e5141543c7f7fe3087500bbb4ac2845cb528a980aa91f8f1e661e2292ff4a5d", size = 34708, upload-time = "2026-08-17T08:35:24.614Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4a/f48f0e3e1b1ab072979fff2a5be899234e28090883e8b519d0b10215d708/xxhash-4.0.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f09ee747e2a5f876cc5ad56947734811828335e13b403dd8ea1e06d77a9dd48d", size = 35650, upload-time = "2026-08-17T08:21:09.337Z" }, + { url = "https://files.pythonhosted.org/packages/c4/53/b73d7472b196101ad1f57ed0674af3af803ac3e9ec2feadd650a7b262562/xxhash-4.0.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:acf52474b2494ef66dc7e0fb6d5e2b50c18313039ad4d275fbf9f9907c804bc5", size = 37958, upload-time = "2026-08-17T08:22:10.616Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/024946ad8fa532074af4e4380179da54b7ec9facc8bd0b279ec0fac4e63a/xxhash-4.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1b3cccf75eeb5b01639b2feadb042a8e07889293b7ca72fa2985e7dcb64763cf", size = 38032, upload-time = "2026-08-17T08:22:09.535Z" }, + { url = "https://files.pythonhosted.org/packages/da/e0/934af8d99bb5885711006bec30a691f728edd513d2c40f053f887d8e7577/xxhash-4.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd878d32f5c6cbce9783f8d6897561fb772211edba9dde49d85672b88ed45276", size = 35895, upload-time = "2026-08-17T08:35:16.53Z" }, + { url = "https://files.pythonhosted.org/packages/20/5f/a8011f6a1558f7ca66d9077bb4f192b1871afcea62fbd5733605d2015755/xxhash-4.0.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:41e579025a6e13a99e6d71e39c9cfc621a0dcdbbf19106325e145fa858f2d794", size = 259464, upload-time = "2026-08-17T08:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/ff/89/9665a44397547e7a3d58c0942425a976d58dcfd4b538f33220a312bf6912/xxhash-4.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74379a577a9f3b6afbdedf1b90e5c7764467051977f18a326d7d607336d743bd", size = 283949, upload-time = "2026-08-17T08:22:17.003Z" }, + { url = "https://files.pythonhosted.org/packages/34/2d/78774141266457468f29f3f5803092df4db87d8148ba74e4debd041649db/xxhash-4.0.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:acb31ecdd1a97fab5cd39a84ee9f515e727d319f796fec48703b8339b9998360", size = 303898, upload-time = "2026-08-17T08:35:27.951Z" }, + { url = "https://files.pythonhosted.org/packages/59/48/d78d22de576b42528bff87c14207de50de4f0b888221a50ff7c9d675d670/xxhash-4.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b7875ac1a2edcb691f27642b8b94b904baa6bcecb7d79c72df2228ba8cb5c51", size = 287241, upload-time = "2026-08-17T08:21:13.042Z" }, + { url = "https://files.pythonhosted.org/packages/4c/de/7a1755a59c59fd46176f293bbdd99e399a6537ba9537fc723aa4d1bf6e27/xxhash-4.0.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4751f1d7eecae6b2d2a773630f1a7248f125c9a92a456694d03c15bceffc9d68", size = 519856, upload-time = "2026-08-17T08:22:15.35Z" }, + { url = "https://files.pythonhosted.org/packages/6f/fb/76580c08e916507859b0f335393cb5fdc59452c4402edbc6bcca6e47e7df/xxhash-4.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a51b061d54cda8b83e62c44458bfbf0dabbef9b975dd9649952ba5076b9f349", size = 268572, upload-time = "2026-08-17T08:22:14.533Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/1abde3e07b8f2077a38b4fbfaf764115008bfe0ff03bc7756a52c9fd0607/xxhash-4.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:74a164e8b63f1e9cf35c9a7809d082b033d1a00e7375d5d814415436e7867e57", size = 344967, upload-time = "2026-08-17T08:35:23.569Z" }, + { url = "https://files.pythonhosted.org/packages/5c/15/80b6ddf0732eef48a8b5fe717398274794392bd6dbe82af38d189d214772/xxhash-4.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f5e5c6df4b703afcbe9352d238a51efd97c3b91fdc3a2052e40fdacb1e7505f", size = 279956, upload-time = "2026-08-17T08:21:24.97Z" }, + { url = "https://files.pythonhosted.org/packages/77/e0/11cbc43c205bf81fad50d69c7319cd1b1ccc01a66cd4fb8766357126c43d/xxhash-4.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d54b8ae068af532c8cdf56abb9e09a60fbe7b10792444c9c27987bb6d3b450fa", size = 307583, upload-time = "2026-08-17T08:22:22.541Z" }, + { url = "https://files.pythonhosted.org/packages/1c/11/cf0bc07feb2791045b6ac075d4bf64f1a5beedef2f46ae70d7104d63a19f/xxhash-4.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1749f0688020209fe0d357ce1e1cd9ec9c6161ed0405ea949d24581c4c43fa91", size = 265848, upload-time = "2026-08-17T08:35:31.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c4/7ada4bea2a2795073dfc42d96842930efbe7a0c1857ef4b522e4e90e5d83/xxhash-4.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:94ac8a6b8c47951173f0b67bf862bcb971bf24e493b9fbbdb0e010cbbc7d9f54", size = 284409, upload-time = "2026-08-17T08:21:23.156Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f4/d8ce83dd6b99ccfbdadaf2db968ae40334d2e5f73a0297e593b9ddb3df39/xxhash-4.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a33de7633c948ab2dc144af370a66e7e7af29b425dcd0f7e4f59689fb9391b53", size = 335921, upload-time = "2026-08-17T08:22:21.802Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9f/f47d8724bd8bc45b395b06b7cacea2dae0d00031af1b707184a091161df6/xxhash-4.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:247ece770647c0aef080561fa996f9774b4dadce2d0c42eeb98229db7dcf820d", size = 487023, upload-time = "2026-08-17T08:22:19.729Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/2d87098f3371cc1e42dd04d2285ad56bca4c56667bc501bff02d2b9fd6b5/xxhash-4.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a4553d36cc0b7fce1f35ba8a94dfd775aa3ed12f5eab2dc3b46ac75a0706b0bb", size = 264333, upload-time = "2026-08-17T08:35:27.001Z" }, + { url = "https://files.pythonhosted.org/packages/27/b8/93795ca5898ec7d7d0455283ad261c0fc76b4f0c0a69e86233bd7badb0bd/xxhash-4.0.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:87aa309a93bd5ec13f14309a305ff4e9bf74c5363fc46c264c0a22edfd5b0670", size = 20581, upload-time = "2026-08-17T08:21:39.207Z" }, + { url = "https://files.pythonhosted.org/packages/b6/96/926f7335a0a1647952c00421e8da877f658094f61336306c7cadc335c94d/xxhash-4.0.1-cp313-cp313-win32.whl", hash = "sha256:cba763d84b06bda2c38d5185dee76f1b9dfdc0789e96e476d9e10005526d0788", size = 34449, upload-time = "2026-08-17T08:22:29.362Z" }, + { url = "https://files.pythonhosted.org/packages/ea/61/8a5aeb811de093bab3434e77eff0e9461624a1a56a6a93d315d080aab2aa/xxhash-4.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:97b94fb29abf21f5f0bde15f7dbdd3a4aa2dc59f37026adc7b4bee8563b84375", size = 36520, upload-time = "2026-08-17T08:35:34.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/14/97f3c74000ca36955e9cb86f6d270dcd5848b5c65afa623453f5cf2d83d6/xxhash-4.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:08ed8da18cd4fd0a6a5d6a444852d8fbd0e565388a74a4937085451b5f1a312a", size = 33428, upload-time = "2026-08-17T08:21:31.713Z" }, + { url = "https://files.pythonhosted.org/packages/81/0e/ea406a02b561d3275232ccfdb3e29df80f7a65414940e3a15721c7bea40f/xxhash-4.0.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:af05a3f650220a6c59fa0ad2410249f2d2470a05225807c378fb67458693f8df", size = 43747, upload-time = "2026-08-17T08:22:31.37Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f0/b0c94d61ccf6b5d1f8847b58ef8f923125ac4919ed5bd0eb082750ca7cbd/xxhash-4.0.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:a6e3653df1a70b8ac4191216324242e4be2bca18c9a7c10934e1bd56dc7ca15e", size = 40749, upload-time = "2026-08-17T08:22:29.431Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c5/8085881a538983be0fd1c865d5df236242fea496044e2c8ca32b9f2ba39c/xxhash-4.0.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:4528cf80ebbbf57d40edfb31521ae265daa6dd636d615b1cf0ac86209579e59d", size = 34734, upload-time = "2026-08-17T08:35:33.68Z" }, + { url = "https://files.pythonhosted.org/packages/d3/94/8803d13c968fc75ca434eea991d29ac5fd8a36b4afc9a6a9803c53933db4/xxhash-4.0.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:90cb2a1c9cc503a054a19612b48ff6e8e47805f618bdb3224a07568aad03a37e", size = 35671, upload-time = "2026-08-17T08:21:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/85/d5/ad91d7f0fd294190d37c08236fe661f5c4e3f83dcd1a121877a2e64681ce/xxhash-4.0.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a949b072ea59c6eca0811ccd9e95133cc50d2afda8d464b5b077c78f78efa269", size = 38094, upload-time = "2026-08-17T08:22:39.763Z" }, + { url = "https://files.pythonhosted.org/packages/89/f4/2b7ebdc1869caca5f02c4cba8379b631050d3c3d4adb9187e4dc1a6b8d3c/xxhash-4.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:79a3203aadf39637869dfea1185227d8452844d78b837e54fb1117b4d34ba5c3", size = 38244, upload-time = "2026-08-17T08:35:38.081Z" }, + { url = "https://files.pythonhosted.org/packages/90/9d/f66cf6935f528e575f1ae4d6560d376e7587569747186f4fae8777cadc1b/xxhash-4.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d9f3848ffaf010bdbabdbf4c25641fa258b6227ff27bc74a4d06edef521a4873", size = 35904, upload-time = "2026-08-17T08:21:37.358Z" }, + { url = "https://files.pythonhosted.org/packages/07/29/34569d7b482f0dc060074faafd163c588f915cbc3e3e218f1ffd8a3ad340/xxhash-4.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9283d9dd6b44acad35118e2976fc763a065509e4118debdb61916ec322ed17b9", size = 259595, upload-time = "2026-08-17T08:22:38.153Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/a2370acfcd48732cf5c2b87f06cfbf7fa51c0ce0dd736bde42939eb9ebf7/xxhash-4.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c7c642a0f79c3e3cf2965475507574d3d1a50ec71060039d60cb87358667cb2", size = 284279, upload-time = "2026-08-17T08:22:36.396Z" }, + { url = "https://files.pythonhosted.org/packages/08/15/17d33c24e6c4a1c0b9ddc5584f0c25d51d48b34bacde1416a2235a19db4b/xxhash-4.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96dedccfb09a73a25751053a183159b88f4ee75f388df8166040c152ac0531c6", size = 303973, upload-time = "2026-08-17T08:35:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e0/4ec0d69ad5738729098a61e631b7ed2df22a922b0e03014b597c72bd863d/xxhash-4.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81664268dba92e037b740ecf37fa02f1cab4a391f93f28e35792b3341c60648f", size = 287535, upload-time = "2026-08-17T08:21:52.158Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8b/4f9b17e7a9eb71c65548ecddd9c18b84e3c18ca41c4d436ad2a3000d3f7b/xxhash-4.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:839f58c5bd9989875be0fd28446dbf32cace2c2cd8bf2f6762acdc38a95cd1aa", size = 519257, upload-time = "2026-08-17T08:22:43.272Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/3276b3e743b8ddbed9c3f71c76d9dd6a75d72aa4e678b1447b635cfd92e0/xxhash-4.0.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffa44b4c7c5d0ffa31356b4428659516c0e47647825c74079a296b3857b6d99d", size = 268190, upload-time = "2026-08-17T08:35:44.985Z" }, + { url = "https://files.pythonhosted.org/packages/08/d4/f1555de3c96721320930dbb7988c8482d82b85970076aba1a8d40e83ad43/xxhash-4.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e681a6fc7e4f715252b9b5acfb30536ec7dd1f75033a32dc617e6fa95af1a3fd", size = 345553, upload-time = "2026-08-17T08:21:41.025Z" }, + { url = "https://files.pythonhosted.org/packages/ac/98/c28908f27007087b61139d290f908dd827ffd40b88af0c43f9e1a1a7ffd5/xxhash-4.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6301d92545c591ad31c3e050aa40a5f8a4c16413f1f9e6f9322c6f0f9d2b736", size = 280499, upload-time = "2026-08-17T08:22:52.236Z" }, + { url = "https://files.pythonhosted.org/packages/a9/76/3ef57622c65816348f8196273485baab4752aae064959901e85cd867e067/xxhash-4.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6efb8f21cc136c79b3e5bb747c8682d37916fb202cdbbc32182de5c4e47f821f", size = 307211, upload-time = "2026-08-17T08:22:40.815Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4c/5804504bbc808968e57d6a50286dd8f8cc06e0ddd6e4ab4b1dc89ae42f35/xxhash-4.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:760de77279e9cf9c81d012ce0705cba13afccee9b09c480f17d778c8c5cefae8", size = 265865, upload-time = "2026-08-17T08:35:42.727Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ee/8572fdfd70e7aaaf150af899871c2cc0bb88c3295ca82172a31e04ca5168/xxhash-4.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a16a3fa6936e36bb1414d16a6bd012c9033e5161b68b426805b61d895392437d", size = 284545, upload-time = "2026-08-17T08:21:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f8/6eadcca0904660c848b466524e82a233d16c9d2d5258433aaf3546142d86/xxhash-4.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9c3c4b9aa9a27196b921197f7daf9e6c1412739df06a99cfa6e923879362eff6", size = 336022, upload-time = "2026-08-17T08:22:46.346Z" }, + { url = "https://files.pythonhosted.org/packages/27/df/4aa107b81602d6d6d09ab5a607c530d2d3a6b28e2e9a59b01875bd877c54/xxhash-4.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:863f3d3b44110f7243e86cf994aa5c5d88f2348b6e84ab4402fadadfbf9f7da7", size = 486671, upload-time = "2026-08-17T08:35:49.016Z" }, + { url = "https://files.pythonhosted.org/packages/45/b7/b2bf9b5301e9cd5f2e335fea8da0f5cf209a6594cb1fe77754774ad4a6fd/xxhash-4.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63aa52659bc32bb9bd7cb5caf523b4d14429a477762cfac886132d687c1f80fc", size = 263744, upload-time = "2026-08-17T08:21:56.165Z" }, + { url = "https://files.pythonhosted.org/packages/0b/96/35b1c02177ae26234892c2310fb4822ba62411acccbf425ab8f9fd99354a/xxhash-4.0.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:67e57b834e07ed973cee7b6da1548ff28a56458d77696fd2a5f397f340694848", size = 20563, upload-time = "2026-08-17T08:35:11.924Z" }, + { url = "https://files.pythonhosted.org/packages/51/c2/a06300b165fbd6b0cb4a9742987f2e997a9f447ce3bf7c6ac97b862ce62a/xxhash-4.0.1-cp314-cp314-win32.whl", hash = "sha256:b6c1f9c59bbe593f88a0aad30be4150f15bd57bd64efb95feeabcb8e563f1ecd", size = 35151, upload-time = "2026-08-17T08:22:44.283Z" }, + { url = "https://files.pythonhosted.org/packages/06/96/c5b37296b78f80fc97124c0fee0c7bbd1bdb6f3b18bcd8748bb113b2d8fc/xxhash-4.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:da544672efd9ad76077928a3e6c5d894e52ce82d3bf14002db4a1bf17d1a36a2", size = 37156, upload-time = "2026-08-17T08:35:46.551Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5e/248f9cd169c2fb62236bedfba246d213bce728f74901e99047e3f3c55875/xxhash-4.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:d0d24a4f3fb63852cd09af46ae4b7a4d00cc8b8615a046dca543786e728d1056", size = 34379, upload-time = "2026-08-17T08:21:59.446Z" }, + { url = "https://files.pythonhosted.org/packages/58/c8/db1d37c0da0324d0298f6abd931ca1d4736e049d9f2081230a8421da74d2/xxhash-4.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:349775ac30372b344d2338b2a168c0a1312a644194da25b8bec476d55761a128", size = 38656, upload-time = "2026-08-17T08:22:49.119Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/e18998ec465fb977bc74272e5bf3c2e886c13b014cbef916cd607802c709/xxhash-4.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:43e5f9169e73d0f0db33b5f6b8554bcce69ac278c966daf83d5eb4eb2f13829f", size = 36306, upload-time = "2026-08-17T08:35:52.853Z" }, + { url = "https://files.pythonhosted.org/packages/ef/1a/b83f86f8a987a3cbcb7e005a6824ff64aecae35abc1395a0d44ee16c3319/xxhash-4.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4a252fb862b0ae2590587e625f47a0e03da05cf0205e8830b67b6596c06038b1", size = 273729, upload-time = "2026-08-17T08:21:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/02/4e/2db15aa8508e0cd5b632927a53b98234f24039ea65377e6cf996c06d2d4f/xxhash-4.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2df3ca8757dc381e75e90a4d7995a6324f58a923c7145220a7b2c0231f66fddc", size = 301083, upload-time = "2026-08-17T08:35:14.113Z" }, + { url = "https://files.pythonhosted.org/packages/26/94/ed759787ffe802bd8e31cfcdad3755cbeca2dcdafd2f790cd6f25d195199/xxhash-4.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bfed61996d618eb90d6eaae0178002e3466a28b06bfc557a7a3a7266378d8c5a", size = 312745, upload-time = "2026-08-17T08:22:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/f64b4a4cc8b51e950709207f55f7f56ae9c5af6631dd31d7fb443312418c/xxhash-4.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9761ff4a0ffa583fe850731ad24fe82c88cccb7a2294727db0955f3279a4cb3f", size = 301419, upload-time = "2026-08-17T08:35:50.143Z" }, + { url = "https://files.pythonhosted.org/packages/a0/71/bac313b8de073569b8db3152044a7cfcce87a3fa9698c18fe9f914dee6b1/xxhash-4.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edccc2ec58435a580f96a48a3ccae8cd0a480824119165dd90108718ad81ae6e", size = 534485, upload-time = "2026-08-17T08:22:11.515Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0c/16b5e419f24e59507ee05626d2bb0deafdb03f9f27783bc0785a9849602e/xxhash-4.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4741d42d59e4e5fa1a86c17ab9c27dc8ea459c700d91b6742fdb9138d9a516cb", size = 279605, upload-time = "2026-08-17T08:22:52.934Z" }, + { url = "https://files.pythonhosted.org/packages/5f/55/5787dd6e2d8d5b61256a5039f6b18c2193c7c1de4a2fd2413288d0d9c604/xxhash-4.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:440c401e146ce64bdb3beb8ff0c84677b6f21307c28a34779071cecee5d4d70c", size = 358924, upload-time = "2026-08-17T08:35:58.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/68/89be41991f3b0a2e91f940bdf3128852c3ed571cf560d98ad0f67024afe4/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5b7979f71d06ae45a769de0699900a246d8cb632db1e8bfdc79ec019063a503c", size = 295305, upload-time = "2026-08-17T08:22:13.683Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5a/52ff0a0cc361aad393ff9a46ffe3aabbcf9c03d6c8f2612da7d553048276/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:62198213fc3e0c56e567894b318ba45834e007d065f84ba6dc9165d21546fc56", size = 320228, upload-time = "2026-08-17T08:35:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/0f/b5/91c60ff22c7f6cd5f6d7a5bad5a2cdcb4c33987dfa50bf13f0d856279b2e/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b3bece52127ac20044311ee73567f9f0893b5de64f9028aecc90cc740cfd525a", size = 279414, upload-time = "2026-08-17T08:23:03.212Z" }, + { url = "https://files.pythonhosted.org/packages/b9/94/9685954804d47d0390871a64bec606a0d536406382d71a784df3a5883fb4/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a865d2d470220e659220fdb59d5b6c4422802d8d6098e1324bc4d12444798914", size = 297594, upload-time = "2026-08-17T08:35:57.881Z" }, + { url = "https://files.pythonhosted.org/packages/89/62/b67ac9412907b7a07a2a0c08c3440b9e4480231a7b3de0767e87011e4564/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8580aab306888224074c7edeec734de0c3c5ccde65b2da4e6c9a5e28f7c0a1bd", size = 348526, upload-time = "2026-08-17T08:22:18.571Z" }, + { url = "https://files.pythonhosted.org/packages/37/ed/6723cc49a9f567d52d01fd7c1741b0f2e3a13e71d15f7ac49d753a20c115/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2d52dc7c33c1b83082b707f6b7814dc76d2faaa2ea62bd9c5fab4b36f83c087f", size = 499307, upload-time = "2026-08-17T08:22:56.52Z" }, + { url = "https://files.pythonhosted.org/packages/fd/2e/7b10e101ab988d93b791023be7191d7661271d6ab31ac082276b9091042a/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a9f98af872355e0c02439e48583958eee00e60b928bb20476460d9d40cb7b4e", size = 274989, upload-time = "2026-08-17T08:36:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8d/7eabcc8d29cce40621443cff24c07d7306ef574b8956c47ac59f21098005/xxhash-4.0.1-cp314-cp314t-win32.whl", hash = "sha256:a14578102a6081465aec9cf73c76c3cd3f79f0709bdb3b8ae7ab0b54c9d8b089", size = 35482, upload-time = "2026-08-17T08:22:32.336Z" }, + { url = "https://files.pythonhosted.org/packages/ca/89/2a4268e1971f63038b79fb75e3b9c8de942cd77acabbb0c5625352a31940/xxhash-4.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c57963970d359a72262f7fe6be88f945e2334d4bc41462b7f08c37b0abf35ca6", size = 37490, upload-time = "2026-08-17T08:35:22.475Z" }, + { url = "https://files.pythonhosted.org/packages/90/7b/950ecab1fe4cf421d0a6211ddd9a0ac82e39e55c45a111ceb90953dc6c9a/xxhash-4.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:b659fad79c99b0238c7ad7e9d7dbf4eebfea9097c2dba65fa0a4d18a25b29a2f", size = 34596, upload-time = "2026-08-17T08:23:10.001Z" }, + { url = "https://files.pythonhosted.org/packages/c4/03/7dc3b85fac10751613bfedb0e120734e0e8710054abad3f931e9d3843a14/xxhash-4.0.1-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:5adf927dca8c47fde7e683fe69efdd81bc865c4db1fb6bb00b391e2b6185207b", size = 43749, upload-time = "2026-08-17T08:36:00.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/55/bfac071c5b1c6d6a3d48ab1ab96a15e958a1d7061f4afc97804292d87264/xxhash-4.0.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:c30dd1af66a820820398b26e0d74e7a9aa43cae705924f23ed828cd8e5c26c3d", size = 40758, upload-time = "2026-08-17T08:22:30.209Z" }, + { url = "https://files.pythonhosted.org/packages/79/87/49a260e685d1a74c56a69432a8ee0527ddcbd684a3c51f87edc3b75639c5/xxhash-4.0.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1bc591533fc975614f7e13594daee76af96b8e1fbcf8de76c8773858fa9e7cea", size = 34788, upload-time = "2026-08-17T08:23:09.014Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/50d72ed2170dae872e1c0fe333d0908e0a2afbffe74c5c9037d5406a4b89/xxhash-4.0.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:567cbc630302a46a8ecfd943b309ccf5372bb3718f1f3762d452df30f033bcf0", size = 35746, upload-time = "2026-08-17T08:36:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/66/f0/969deaa2bab3bfd5ad5b023442124d2255b9961eef6f797ec74eb8683bdf/xxhash-4.0.1-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e998cb3685b92101ec5de0fb4d9485cf01e50bc418211955c55d98064664cf4c", size = 38098, upload-time = "2026-08-17T08:22:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/86/aa/45ed7d7b8d7b66202a47bf8ff3b77cea28d2ea54dfcdd202b4cfe043e3dc/xxhash-4.0.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c3074db513c81f764053e3da079312ecf85a50d8350c71f4cc0105d9662a9e6c", size = 38251, upload-time = "2026-08-17T08:35:25.774Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9d/45e7520a7856e13800a5dc8cd038d34c6372429465b163af0c5722f16918/xxhash-4.0.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3088dadbffa33c29e0518578430a7dff2e901a212e487aefa5faaa0dc06dad34", size = 35986, upload-time = "2026-08-17T08:23:25.854Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/5ad466e5fea18c9f9bdc5828c0506f62190061b4a1b0e688aa54969d0a9e/xxhash-4.0.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1b50223d92df94d54e1a31469335a2c74b16692e6c1cb726f1e6949514458706", size = 264609, upload-time = "2026-08-17T08:36:04.229Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cf/8f269f85217e3dbd45e31e25e46cc26f3aff0e159ef05d228b4b982c778c/xxhash-4.0.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:427b62d62d4f967fbb10b82a3813e4875c2a6e7e7634739f17265b650c7f65a6", size = 285910, upload-time = "2026-08-17T08:22:38.589Z" }, + { url = "https://files.pythonhosted.org/packages/ca/30/2fc1a16ee0f9501d074b798ebfae52e24fa602c7117f5c4b81de71eada72/xxhash-4.0.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c6370189e8e66b7e608f533b939a9de092ddca6cce084ca0d3d414d2ed5b5d59", size = 306566, upload-time = "2026-08-17T08:23:16.895Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a7/08375cf2b997e1903663fe7525c5973b1987a4f8ad2b8d47463e9143f2ee/xxhash-4.0.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec1a470c6db94ac4589c203921e89ac1bc13e796a8b1784d8135e1893559cd3b", size = 287978, upload-time = "2026-08-17T08:36:09.296Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/90a7b404c11add9e53a497d06236152852490c3b2f21e468d97a58f26afe/xxhash-4.0.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37f667dee0f867c42894b34e2a6fe26bf195c0ea4683d9d2b713db023f242c3a", size = 520098, upload-time = "2026-08-17T08:22:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/11/02/7fba10b1b17eb46308f09cc0a4ed513d74dff16b1e22a1c439f011c77129/xxhash-4.0.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f18732adcc271741bd651c3e56fa519d8a237d2cccda01fe3afb226bf87f783b", size = 268273, upload-time = "2026-08-17T08:35:29.043Z" }, + { url = "https://files.pythonhosted.org/packages/54/49/c21b228877357a3be43eeeaa22182ad1685796f415390ada475922c084e4/xxhash-4.0.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b42a5a26607e4b2409fea174773a66f2dff9dfdbf2c1a851bb7b804e2c97535", size = 350164, upload-time = "2026-08-17T08:23:29.494Z" }, + { url = "https://files.pythonhosted.org/packages/00/3c/c15bb4aa33d94b78a5553b52e7fa1070565f0199925aeadec3871de20ce9/xxhash-4.0.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:99166cc98637e8bf550cda2aab07f4f1d5f899c45fbd721801aeabcc9d404824", size = 281975, upload-time = "2026-08-17T08:36:08.139Z" }, + { url = "https://files.pythonhosted.org/packages/18/7a/b1d0388315fe7752b7725b68a912667526a1dd48ed492fcc031ac03f4b52/xxhash-4.0.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6cf633df84d80a1668fcf61e330791dae46825e395549e7d34f376411e75088a", size = 307872, upload-time = "2026-08-17T08:22:42.206Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a1/037cb2dd8cf725c9565dfe3712b2915c0e0276a9154913dbfcbcecbeb672/xxhash-4.0.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:e259bb7e1e2d8de6b35f430f5c7220b1c0ebf3962d1ba7ec7545980d5931edb8", size = 268241, upload-time = "2026-08-17T08:23:23.997Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a9/67c44422d0ee082169b238ce24bd2796b82d7c21ed953471365df8c508d8/xxhash-4.0.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:704381264b36a18b9c62ecbabe2e71d0fc58c77c129c15355c989b10bf05b6b0", size = 284970, upload-time = "2026-08-17T08:36:13.476Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d0/254a5f51c4014cacc77a26f321372338b924f54e89efb730164ee336d850/xxhash-4.0.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:e90b4bcf1d9eb1010fdaee7c9209fb667e74c0684f3ba17f9032bd7319da90c9", size = 338074, upload-time = "2026-08-17T08:22:51.166Z" }, + { url = "https://files.pythonhosted.org/packages/64/03/f21c4830118d72ef3a958ce8bf2152f49e0d4cf200907616c9be6caf372a/xxhash-4.0.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:a65785e653573fcd1e33062760ab4c3c3440e8e910765018e4b6ed4ad07b54a0", size = 487626, upload-time = "2026-08-17T08:35:32.768Z" }, + { url = "https://files.pythonhosted.org/packages/45/1f/268a689d741d7da649317eb4ce41760140beb4179aaf43a7216fdbe8100c/xxhash-4.0.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e3996ff9b6f99180357024336bf5749a8ad6476a9a2523e535c5212b995b12a2", size = 263852, upload-time = "2026-08-17T08:23:41.871Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/adaf8101cd7f143191a0b390600294d83924b32cb13770fde8803dce27a2/xxhash-4.0.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:99054b838b74d8d3995ea0d410976ae967c46207ae22d6ddfc535e809197dab9", size = 20569, upload-time = "2026-08-17T08:36:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/ee/2c/56a5eb8c993420fc07114c08f447a2b66ee996510b4764cb368b9b44c9f0/xxhash-4.0.1-cp315-cp315-win32.whl", hash = "sha256:6c45258a37fc22721395c09927cb982d3e7a83607cab15be7e2416501bd3a330", size = 35145, upload-time = "2026-08-17T08:22:50.038Z" }, + { url = "https://files.pythonhosted.org/packages/67/c7/65f210db43e62157d0fef3b4d4d7b394821e7733c8bb4ece49f91410a725/xxhash-4.0.1-cp315-cp315-win_amd64.whl", hash = "sha256:0ab851b45c70d4992be7cdeeee16f97a0b677408c758c4b1efb1cfe8030bfd37", size = 37161, upload-time = "2026-08-17T08:23:32.438Z" }, + { url = "https://files.pythonhosted.org/packages/33/ae/1a641d1d60ba219756d9ebe907ff0ecf4445adcf4fa96f6e3da57b91d439/xxhash-4.0.1-cp315-cp315-win_arm64.whl", hash = "sha256:a5b21b42a01a343096a1c018d35e9b7aec9c7065dda53ae8da071e37478b2cea", size = 34378, upload-time = "2026-08-17T08:36:15.912Z" }, + { url = "https://files.pythonhosted.org/packages/48/7f/7698b320b251806d1249e513922a626f19027e104c829a611272250350eb/xxhash-4.0.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:44ab12e8cd17d4f001769f00ad465208b4bcb897ed29e65f058f74466b57a98f", size = 38610, upload-time = "2026-08-17T08:22:55.203Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/436497e775b647b3b3e9a4ffe8c76c59fa4aa7a9fab6447cb59acf1b50ea/xxhash-4.0.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:45e88111ebe331de478ef8d4293efbe88f3cf8b863386c9a2357136b838e1af0", size = 36378, upload-time = "2026-08-17T08:35:36.18Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d8/17a4f8182b9257898aa2a77c2a45f70233eb8e50681a280e8e09d2ee76e9/xxhash-4.0.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bf430c587f447a554c53768ad76b9846fe7c5632180ef6f69c4fce8b0552fbd0", size = 273559, upload-time = "2026-08-17T08:23:51.075Z" }, + { url = "https://files.pythonhosted.org/packages/83/28/121bd5a5c5adb88e0da772c7bef61964cf9da92956a7a237c7d24c4351b8/xxhash-4.0.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adbd48b30e3f82c89fb2b3e6a87cdd28d113b190a5ed0ee2dee286323ee9a621", size = 299133, upload-time = "2026-08-17T08:36:14.731Z" }, + { url = "https://files.pythonhosted.org/packages/11/8f/57c7b6e04642ed738a0d08a31bed7fc63fdacb661d665f98739cc9751b62/xxhash-4.0.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e71b34978e77868cbf2d18c5206a4603f9c644dd7181bec5643bd40141d3b8c5", size = 314527, upload-time = "2026-08-17T08:22:54.224Z" }, + { url = "https://files.pythonhosted.org/packages/8e/18/42793917dbab0ea1ff71458aea4875e17a7263f2797b798af048dc81e867/xxhash-4.0.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:488ca5c5e28ef56ec4bbb12f835b3f1cbecc5f3510062e70117bc6594851932a", size = 299199, upload-time = "2026-08-17T08:23:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/37/60/51dc92443923d8e908d5614f1145d8d696450f9d6c8f1abe243c6f2a0222/xxhash-4.0.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:421b94f3ba7067958d02e38960d987756347aa150df06df11aa68ae1af78c619", size = 531967, upload-time = "2026-08-17T08:36:18.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/c5/d0de77de09661fac71742c4155b1cd65e274f7cc277819d702b6c8ff2db5/xxhash-4.0.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f33cf0baa91eccd2cb7b62bf00f10c2264ef578b71dd33a12962e71a36eb4d32", size = 278764, upload-time = "2026-08-17T08:22:58.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/9a/589929c655aba1bfb2c41ee03e50eec1547c39c3042a66bda9c173a9614b/xxhash-4.0.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23a4376b4a3183cb50d4d2a3179f887a7773cc695eb2c908e551bec3221b8c60", size = 359876, upload-time = "2026-08-17T08:35:40.35Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a8/c1d8c94d54d91db2215565f4b4151c1593af3e6d27ac4c00fd1e8d714a02/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:38c3d22129a6958846a3098d68bc8e661704461c0be4793ae28836e4690c8478", size = 293548, upload-time = "2026-08-17T08:23:54.951Z" }, + { url = "https://files.pythonhosted.org/packages/8b/67/85d8abca94508a4dd10561d9dea3e6e68843c6986dd6d9c1b3729c8622e4/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:87cbdec1a7dd930079671a60b249f3ca4e773e6fbd0676e21e36fdc9dd0f3b00", size = 318780, upload-time = "2026-08-17T08:36:17.623Z" }, + { url = "https://files.pythonhosted.org/packages/1b/16/2b920ed456b9cdcfc99ddc20c3afe42f9f807ee5850773c12fd891f3c08d/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:6cbf4e21ef0890804b5bb9ad25c48f9c127758d7f6c66bef374efcacc63c738a", size = 277582, upload-time = "2026-08-17T08:22:57.156Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cc/5811b5997aebb8452047f5800d32fc50eaa29d0ba08d4e426f84450b9c2f/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:c101180495cb4ba3617b279a944345c53a5e73b0c150053d1fa8d8af32de9579", size = 295116, upload-time = "2026-08-17T08:23:40.868Z" }, + { url = "https://files.pythonhosted.org/packages/2d/dc/c2f3f9c2f4d6aadb79f17a9f1c9a7ee82638cc873680da044cf29537d2ee/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:c0e6ccc2b19ec8a726b2e26062ac71ea63e15500d6bf85910e42481844fdffc1", size = 347065, upload-time = "2026-08-17T08:36:21.618Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4c/750cc642c92252e10772ec09e1a1d995581ba4c3ceb24f6e2d57c7ce47ca/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:8bcba9456242ebf180a04d9443812fd85ffe6bd12bda464dd116fcece8886ff3", size = 497208, upload-time = "2026-08-17T08:23:17.88Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2d/58693cb13d6395f39b6b9bb40c5e0db53a5df7c9fce805aa7e792f64a1a5/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:83b8c2013edb5dc1f9e7268b6496130705bc48d79c86bb8817b3d210b81a5513", size = 274338, upload-time = "2026-08-17T08:35:44.062Z" }, + { url = "https://files.pythonhosted.org/packages/4a/08/9aa9787586d9b3e92d63343ce7dc24f0f445fd9e74ff5d6e85dd82233df5/xxhash-4.0.1-cp315-cp315t-win32.whl", hash = "sha256:aa6ccc7f31018484d652cf52db020003433f3c9fa83189c028bd807d2adde503", size = 35471, upload-time = "2026-08-17T08:24:05.795Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ab/4615789c333bee331ac417885c50105715eeb8244bfc68d2bc37dcfd63ca/xxhash-4.0.1-cp315-cp315t-win_amd64.whl", hash = "sha256:daade8936c4deaaf7b01561324ce438ba4f885d717e9adc62b4d67212ad7d7bd", size = 37488, upload-time = "2026-08-17T08:36:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/fb/81/49f718beb0c55d0411bc4bd90b50a3fbe5863a0e97a2f4d11682ba13d298/xxhash-4.0.1-cp315-cp315t-win_arm64.whl", hash = "sha256:f00330ac7e24769e2032203f2b01794d670916b0c1799fd261340f1af9499875", size = 34590, upload-time = "2026-08-17T08:23:19.597Z" }, + { url = "https://files.pythonhosted.org/packages/86/79/9127ff42a887a348dc4ce3211cf1a962836887adee6f57078132bfba78b4/xxhash-4.0.1-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:ff48915bf1871a1f19f74c11834c6329443d306cedc0c05fe7fe617810422a80", size = 31836, upload-time = "2026-08-17T08:36:28.261Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/f238693bfdd642adb59c99683964d46d9947fe721ff44d3bd850ae675407/xxhash-4.0.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a76345f5aceb4ec404918edf9c7f2b5507db864dc0d7455982009ac0890b57b", size = 34453, upload-time = "2026-08-17T08:23:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/40/4b/796ace33cdfb75c91ba6d11615c3bd436355b9f3103e05865bbee9abce57/xxhash-4.0.1-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31d86f9e81f3e84e00131ac7c54caf5119ae4ddd82c09c31cff597c813ce1ee2", size = 38488, upload-time = "2026-08-17T08:23:59.901Z" }, + { url = "https://files.pythonhosted.org/packages/ad/23/2d549e5d5d7759eaf9ac2d2d2ab81ff60f1bb2b52cdaae8e5ec5c6524354/xxhash-4.0.1-graalpy312-graalpy250_312_native-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deca2a30d983d240b8375ec2ee0a4288e72042827fc61df2f7671f8467e4cb2f", size = 38206, upload-time = "2026-08-17T08:36:32.193Z" }, + { url = "https://files.pythonhosted.org/packages/79/98/1ee576b27f78e6107ee4ea8ac03e8a52888dff256e57d560f8282c195563/xxhash-4.0.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:7c343ee174d417a44d0c3355602c0cbbfa52a04d1bbbf1723378c7d2c8f60626", size = 37127, upload-time = "2026-08-17T08:23:42.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c5/8b528f4a394a1eabfd5d8b4271f24fff67fa1aebe58bdd3c64967662e6fe/xxhash-4.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:26fe6238c2d5b11ed5063b9bf4eb290624b004fd074688da6bb079bd564f10d7", size = 36450, upload-time = "2026-08-17T08:35:59.217Z" }, + { url = "https://files.pythonhosted.org/packages/b6/03/909926dcedb64ceee629e18d8dffa758f9d3fdbe01e25530ce2bf0eaf9d0/xxhash-4.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:e53926e76131a74e79cc0b39fa712c227875f180afc68646bd1e1d8a17e60313", size = 33467, upload-time = "2026-08-17T08:24:12.924Z" }, + { url = "https://files.pythonhosted.org/packages/13/b5/ad50456fbfed07f29169bc3fd34e70cbee306a88c17d1a1141a7638de0ee/xxhash-4.0.1-pp310-pypy310_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0718ad66f4ded2411f8e62bdba549ee71e313a2d26ef5060ca3fdbf29897dd3c", size = 48048, upload-time = "2026-08-17T08:36:30.847Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/83cfe6b591a3067ab3f1339931c91211a8b3864d0ea76ad8184f0c1b0515/xxhash-4.0.1-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2200b98a805351cb3142ae4e1fdcc9e91b5e20f5d30d4862b0b96f92558f4e", size = 42719, upload-time = "2026-08-17T08:23:53.581Z" }, + { url = "https://files.pythonhosted.org/packages/20/2e/0f654f2831abba09d502751e38816759086dd92749501b128b7c3676ea87/xxhash-4.0.1-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea5ecf800b45bdb34afe05a1d0dae1f8ea02a290e50636dccd399063f6b180f8", size = 39477, upload-time = "2026-08-17T08:24:03.735Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d4/375b9e4b719ee70e0a6d9e98e2b60aab09efd59f46c7871a112a2e6b0fcb/xxhash-4.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ce6d5cc94a50291d080259a126cbf1e9ba4ac861e6429d2f3cdbb1474f51945d", size = 37244, upload-time = "2026-08-17T08:36:35.452Z" }, + { url = "https://files.pythonhosted.org/packages/ea/4f/e0648288a17d0d1084ca4f7bef206097831988fc86af74aa1dff8f1fbd68/xxhash-4.0.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:554f87034635bcec47c5d72447bf3db7e02da1bf493a0ada010db28a76f891c6", size = 36333, upload-time = "2026-08-17T08:23:52.913Z" }, + { url = "https://files.pythonhosted.org/packages/22/15/34b7f72e9b5a8bfd7e6178de9e1e342bc3de9f07111a5ae26c00506d9edf/xxhash-4.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3c2445edafc300cc40feb6a25a8356a971c30cd0bf47b5349c2ad74c508343b1", size = 33519, upload-time = "2026-08-17T08:36:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/06/4b/e0af324ccf701bf84a7a060bef11c915d45d9e3c5b9caf5b94d62ecb040b/xxhash-4.0.1-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bdd16718b63aa3ebd68aabb79021a40e47c81374852d41a306b9453141bbcbee", size = 47995, upload-time = "2026-08-17T08:24:14.335Z" }, + { url = "https://files.pythonhosted.org/packages/59/46/cc7130e6ca6b41ab72eb6a03177b933c7c74145545c99a8610ecc208c449/xxhash-4.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b99ebaf9e816ac5069423b1367ee7e8078fbcebcf62545506bb0608d2f4f468", size = 42725, upload-time = "2026-08-17T08:36:34.144Z" }, + { url = "https://files.pythonhosted.org/packages/1c/24/4f26ff9a7dd0998f6d1036bdddef7ce3e78972a74f7fffa7967e7bc3b7e4/xxhash-4.0.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f484ed57bb3e4142f9d6439568658c38be5f94b702ba00a1ff32c69783b6c66d", size = 39532, upload-time = "2026-08-17T08:23:57.556Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f3/1ac078fc8fceadcf066469acecacb35d2821cbfaf7d6fc5ac2107c7a314d/xxhash-4.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fac4832b638000106207bc44e44b9616a6a416aaee56c62b01d61f3705e49f58", size = 37252, upload-time = "2026-08-17T08:24:06.652Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ac/cacdda1f0a90441297210bc34cf7e4ac1b7318c8030ebd83bdf6fe82f1db/yarl-1.24.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750", size = 135466, upload-time = "2026-07-20T02:04:21.695Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a5/1b2ceace0230e40c52ab1b263148059a43a6303219b996affc68f8381836/yarl-1.24.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2", size = 97291, upload-time = "2026-07-20T02:04:24.045Z" }, + { url = "https://files.pythonhosted.org/packages/59/1d/340d1a0db7bbce1f291afc044255ebf4ebbce2b25ab1b3f7d3d069080f5d/yarl-1.24.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871", size = 97154, upload-time = "2026-07-20T02:04:25.761Z" }, + { url = "https://files.pythonhosted.org/packages/05/41/25596a33c2fb5098dca8dc3773b04221db64ded0b7f8f09885647d864610/yarl-1.24.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ac73abdc7ab75610f95a8fd994c6457e87752b02a63987e188f937a1fc180f0", size = 109196, upload-time = "2026-07-20T02:04:27.543Z" }, + { url = "https://files.pythonhosted.org/packages/f2/df/dd9f2fb8a5c6054fbefd1538d2b9b1127e612d2ee64b307a070173b57afd/yarl-1.24.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4d97a951a81039050e45f04e96689b58b8243fa5e62aa14fe67cb6075300885e", size = 102556, upload-time = "2026-07-20T02:04:29.16Z" }, + { url = "https://files.pythonhosted.org/packages/cb/57/4754b9d2c8945880290ecba0864e8b0441e117bba70534fe819e3645e174/yarl-1.24.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe7b7bb170daccbba19ad33012d2b15f1e7942296fd4d45fc1b79013da8cc0f2", size = 117965, upload-time = "2026-07-20T02:04:30.845Z" }, + { url = "https://files.pythonhosted.org/packages/74/b5/6a9ece27d2043c3386f902dd078ab35d29ef5126b3206ebffb673283a7cb/yarl-1.24.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89a1bbb58e0e3f7a283653d854b1e95d65e5cfd4af224dac5f02629ec1a3e621", size = 116266, upload-time = "2026-07-20T02:04:32.573Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bc/a6653249f6ee59ec85dcfec008d9cbc16586dad613963bb17a91b2b993a5/yarl-1.24.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba", size = 110758, upload-time = "2026-07-20T02:04:34.235Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c5a12fb8208df7b981bc82256e7831ce428eeaf893f7bbe6179c57bb9252/yarl-1.24.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4103b77b8a8225e413107d2349b65eb3c1c52627b5cc5c3c4c1c6a798b218950", size = 110120, upload-time = "2026-07-20T02:04:35.85Z" }, + { url = "https://files.pythonhosted.org/packages/04/6c/1b659b964626694667b3ec01bf4bcff564b73ae7c48ea1fbfe588b78b461/yarl-1.24.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9f3e9c8a9ecffa57bef8fb4fa19e5fa4d2d8307cf6bac5b1fca5e5860f4ba00", size = 108834, upload-time = "2026-07-20T02:04:37.67Z" }, + { url = "https://files.pythonhosted.org/packages/74/a6/bf48f55c2104e40c15b7b13fad0a5756a11552a55f01c90bc90a66ab81c3/yarl-1.24.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0ebc836c47a6477e182169c6a476fc691d12b518894bf7dd2572f0d59f1c7ed", size = 103442, upload-time = "2026-07-20T02:04:39.576Z" }, + { url = "https://files.pythonhosted.org/packages/37/ac/84b273ac133ecdce598fc1f4140a08a1bf2044048bff8106371d207d105f/yarl-1.24.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:96d30286dd02679e32a39aa8f0b7498fc847fcda46cfc09df5513e82ce252440", size = 117413, upload-time = "2026-07-20T02:04:41.549Z" }, + { url = "https://files.pythonhosted.org/packages/a4/55/9307e03977d3b290dfa42e5d2bae7b6140808fd1786fbe70cd9d3bee53c5/yarl-1.24.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:fd8c81f346b58f45818d09ea11db69a8d5fd34a224b79871f6d44f12cd7977b1", size = 109498, upload-time = "2026-07-20T02:04:43.468Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/791a6f314cb4c989c19f8e3a10271f1e469c077143915e52474d80f26b4b/yarl-1.24.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5c55256dee8f4b27bfbf636c8363383c7c8db7890c7cba5217d7bd5f5f21dab6", size = 116062, upload-time = "2026-07-20T02:04:45.319Z" }, + { url = "https://files.pythonhosted.org/packages/19/1a/ddd3807b86055010e2f99aa89b3c640effdb65696766c20597f696f48a1c/yarl-1.24.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9f4d8cf085a4c6a40fb97ea0f46938a8df43c85d31f9d45e2a8867ea9293790d", size = 110941, upload-time = "2026-07-20T02:04:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/6d/03/f34271bba042d2187508bf62aea20a14129efb5a1acfc6a2efe7544630b4/yarl-1.24.5-cp310-cp310-win_amd64.whl", hash = "sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224", size = 97534, upload-time = "2026-07-20T02:04:48.774Z" }, + { url = "https://files.pythonhosted.org/packages/e4/02/ecc8dc31b9f355731e700f8402b8075d2ea1737dbc4baf4abf0f0fc64288/yarl-1.24.5-cp310-cp310-win_arm64.whl", hash = "sha256:8a6987eaad834cb32dd57d9d582225f0054a5d1af706ccfbbdba735af4927e13", size = 93603, upload-time = "2026-07-20T02:04:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/fe/db/3cb5df059756a45761cc3dee8fd25ec82b83a6585ea3542b969fda850f99/yarl-1.24.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3", size = 135043, upload-time = "2026-07-20T02:04:52.39Z" }, + { url = "https://files.pythonhosted.org/packages/44/f8/767d6bd5a03db63bc467df2fb56d6fafeae9667d74aea92cd6af399f828b/yarl-1.24.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a", size = 96942, upload-time = "2026-07-20T02:04:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/ce/97/10b939c44d7b28d1dbc389cfc7012306d1ea8dba01eaef44b39fffaee52a/yarl-1.24.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840", size = 97046, upload-time = "2026-07-20T02:04:56.638Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7a/b410dbe39b6255c55fb2a2bcee96eb844d0789235ddc381a889a90dc72d6/yarl-1.24.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966", size = 110512, upload-time = "2026-07-20T02:04:58.955Z" }, + { url = "https://files.pythonhosted.org/packages/83/c7/da591971f78a5617e1f21f5699858ebccd836fe181a6493788ffc91ba69b/yarl-1.24.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723", size = 102454, upload-time = "2026-07-20T02:05:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8e/73b0ed4de47289a78a96045d76d1cfe5e41848bf0da59ce25b2ec87ee05d/yarl-1.24.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb", size = 117617, upload-time = "2026-07-20T02:05:02.325Z" }, + { url = "https://files.pythonhosted.org/packages/cf/14/b744747bc4f57a8d55bd744df463457524583e1e9f7538b5ace0346ab92e/yarl-1.24.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780", size = 116135, upload-time = "2026-07-20T02:05:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/66/ca/95aa4d0e5b7ea4f20e4d577c42d001ed9df207569fdb063cc5ed4ebb496b/yarl-1.24.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e", size = 111935, upload-time = "2026-07-20T02:05:05.738Z" }, + { url = "https://files.pythonhosted.org/packages/72/0d/d2ad8d6b147832d177a4e720ba1962fe686eb0913b74503b3eca094b8bba/yarl-1.24.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2", size = 110010, upload-time = "2026-07-20T02:05:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/50/18/eb335e4120903903f4865041355ae46256a2406eb2865bc24827f4f27b61/yarl-1.24.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58", size = 110058, upload-time = "2026-07-20T02:05:09.246Z" }, + { url = "https://files.pythonhosted.org/packages/44/70/97353add32c62ad6f206d948ac5a5ee84398225e534dc6ed6433d1b335b6/yarl-1.24.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61", size = 103308, upload-time = "2026-07-20T02:05:11.31Z" }, + { url = "https://files.pythonhosted.org/packages/68/39/5e7398d4b6f6b3c9062823ebc60802df5b272e3fe9e788f9734c6ee46c85/yarl-1.24.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6", size = 116898, upload-time = "2026-07-20T02:05:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/09e52f2239e8b96357eccca05915382e4ba5405ebfb623b6036040d99654/yarl-1.24.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f", size = 109400, upload-time = "2026-07-20T02:05:14.821Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6a/e94133d4c2d1a14d2384310bf3e79d9cf32c9d1eae1c6f034fb80d098fa1/yarl-1.24.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077", size = 115934, upload-time = "2026-07-20T02:05:17.78Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/34955ed967b976fc38edcbb6d538dee79dbda4cb7fc7f72a0907a7c78e0f/yarl-1.24.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd", size = 112178, upload-time = "2026-07-20T02:05:19.675Z" }, + { url = "https://files.pythonhosted.org/packages/f5/46/d7bd3a8859d47dcfaffd7127af7076032a7da278a9a02e17b5f37bfb6712/yarl-1.24.5-cp311-cp311-win_amd64.whl", hash = "sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25", size = 97544, upload-time = "2026-07-20T02:05:21.523Z" }, + { url = "https://files.pythonhosted.org/packages/01/69/c1bfd21e32c638974ea2c542a0b8c53ef1fa9eff336020f5d014f9503ff2/yarl-1.24.5-cp311-cp311-win_arm64.whl", hash = "sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a", size = 93359, upload-time = "2026-07-20T02:05:23.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" }, + { url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" }, + { url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" }, + { url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" }, + { url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" }, + { url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" }, + { url = "https://files.pythonhosted.org/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" }, + { url = "https://files.pythonhosted.org/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" }, + { url = "https://files.pythonhosted.org/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" }, + { url = "https://files.pythonhosted.org/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" }, + { url = "https://files.pythonhosted.org/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" }, + { url = "https://files.pythonhosted.org/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" }, + { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, + { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +] diff --git a/src/hooks/fp-home.ts b/src/hooks/fp-home.ts index a5e58d7c2..45b2ef80e 100644 --- a/src/hooks/fp-home.ts +++ b/src/hooks/fp-home.ts @@ -273,6 +273,48 @@ export const auditSessionFile = (home?: string) => resolve(auditDir(home), "sess export const auditMachineFile = (home?: string) => resolve(auditDir(home), "machine.json"); +// ── fp-cli ─────────────────────────────────────────────────────────────────── + +/** + * The Cloud CLI's own directory (`fp-cli` on PyPI, command `fp`). + * + * Written by PYTHON, not by anything in this repo's TypeScript or Rust — the + * CLI resolves it independently in `fp-cli/fp_cli/config.py`. It is declared + * here anyway because this file is the register of what may exist in the home, + * and a path absent from it is only safe by accident: `resettablePaths()` is a + * filter over `HOME_CLASSES`, so an unregistered directory survives today and + * survives the next migration only until someone lists its parent. + * + * Not classified itself — `auditDir`'s rule applies, classify the children. The + * directory may grow a cache later; the credential in it must never be dropped. + */ +export const fpcliDir = (home?: string) => atHome(home, "fpcli"); + +/** + * The CLI's signed-in session. `0600`, written only by `fp login` / `fp logout`. + * + * Was `~/.fp/cli.json` — a third top-level dotfile for one product. The old + * file is deliberately left where it is: `load_config` in + * `fp-cli/fp_cli/config.py` reads it when nothing is here yet, writes the + * session to this path, and does NOT delete the original, so downgrading to a + * previous `fp` finds its session intact. Adoption is best-effort — an + * unwritable home hands back the session it found rather than logging the + * machine out — and it costs the user no login. + * + * This paragraph said the opposite until it was checked against the code: that + * there was no migration and the upgrade cost a login. There is one, it is + * covered by `fp-cli/tests/test_failproofai_home.py`, and the only true half + * was that the old file survives. + * + * `user-typed` for the same reason as `auditSessionFile` beside it: nothing + * regenerates a session, and dropping it silently signs the machine out. + * + * TS-side only, like `auditSessionFile`, and absent from `paths.rs` by design — + * the daemon has no reason to open a human credential, and mirroring a path + * only Python writes would give `paths.rs` an entry nothing there reads. + */ +export const fpcliAuthFile = (home?: string) => resolve(fpcliDir(home), "cli-auth.json"); + // ── Hook activity ──────────────────────────────────────────────────────────── /** The decision log: page-sized JSONL the dashboard's activity tab reads. */ @@ -469,9 +511,15 @@ export type DataClass = * else under it is scratch. Listing the parent is exactly how a reset came * to delete undelivered events and the machine's own telemetry identity. * - * `home-classification.test.ts` asserts every exported path function in this - * module is either classified here or covered by a classified parent, so the - * next path added to the home cannot skip the one question that matters. + * `__tests__/hooks/fp-home.test.ts` asserts every exported path function in + * this module is either classified here or covered by a classified parent, so + * the next path added to the home cannot skip the one question that matters. + * + * It cited `home-classification.test.ts` until that was checked — a file that + * has never existed. The guard was real and in the wrong place, which is the + * failure this header already describes happening once before: a citation is + * only load-bearing if somebody follows it, and the person who does is looking + * for the rule they are about to break. */ export const HOME_CLASSES: readonly { path: (home?: string) => string; class: DataClass }[] = [ // ── Never deleted: a person typed it ── @@ -498,6 +546,11 @@ export const HOME_CLASSES: readonly { path: (home?: string) => string; class: Da // with no notice, and the machine only finds out the next time it tries to // report. { path: auditSessionFile, class: "user-typed" }, + // The Cloud CLI's session, written by Python (`fp-cli/fp_cli/config.py`). The + // one entry here whose writer is outside this repo's TS and Rust, which is + // exactly why it needs listing: nothing in a migration would otherwise know a + // credential lives under `fpcli/`. + { path: fpcliAuthFile, class: "user-typed" }, // ── Never deleted: recorded and not yet shipped ── // Batches read out of transcripts and queued for upload. The reason losing diff --git a/src/hooks/fp-reset.ts b/src/hooks/fp-reset.ts index cf7f67aa5..47df6ddec 100644 --- a/src/hooks/fp-reset.ts +++ b/src/hooks/fp-reset.ts @@ -1344,8 +1344,12 @@ function staleDaemonHint(): string[] { `[failproofai] daemon is ${skew.installed}, CLI is ${skew.expected}.`, `This machine is configured to REQUIRE the daemon. A daemon built against a`, `different on-disk layout refuses to start, and this version moved it — so the`, - `next reboot or restart can leave the service down, which denies every tool`, - `call until it is fixed.`, + // The wrap is load-bearing: "denies every tool call" is the consequence + // this message exists to state, and splitting it across two lines is + // what made the test asserting that phrase fail while the text looked + // perfectly correct to a human reading it. + `next reboot or restart can leave the service down, which`, + `denies every tool call until it is fixed.`, `Run \`failproofai update\` now to bring the daemon in line.`, ``, ];