diff --git a/.github/actions/port-toolchain/action.yml b/.github/actions/port-toolchain/action.yml index 31424e6..48a74d5 100644 --- a/.github/actions/port-toolchain/action.yml +++ b/.github/actions/port-toolchain/action.yml @@ -14,4 +14,6 @@ runs: - name: Sync dependencies shell: bash - run: uv sync --all-extras + # --frozen: fail on uv.lock / pyproject.toml drift rather than silently + # resolving something other than what was reviewed. + run: uv sync --frozen --all-extras diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ad71362..70e9085 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,8 +1,15 @@ name: CI -# This repo had no CI. Without it the port verifier's pytest/ruff/mypy calls only -# ever ran inside the sync job, so a generated PR reached review with no -# independent signal. This runs the same checks on every PR and push. +# This repo is an auto-generated port: `scripts/upstream` runs an LLM against +# .upstreamer/upstreamer.md and opens a PR. CI is therefore the only mechanical +# thing between a generated diff and main, so it gates on the things that +# actually break a port — cross-version behavior, type safety in tests as well as +# src, coverage that cannot silently decay, and an installable wheel. +# +# Required checks (branch protection): +# check (py3.9) · check (py3.11) · check (py3.13) · types · build · verify-port +# Deliberately NOT required: e2e — it exits 0 when the API key is absent (forks), +# so requiring it would be a green rubber stamp. on: pull_request: @@ -10,22 +17,44 @@ on: branches: [main] workflow_dispatch: +# Supersede stale runs on a PR branch. Pushes to main are never cancelled: that +# would leave gaps in the main-branch signal. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: + # pyproject declares requires-python = ">=3.9.2", but CI used to test 3.11 + # only, so a 3.9- or 3.13-specific break could land unnoticed. asyncio + # primitives are the real hazard here: asyncio.Condition() binds the running + # loop eagerly on 3.9 and lazily on 3.13. check: + name: check (py${{ matrix.python-version }}) runs-on: ubuntu-latest timeout-minutes: 15 + strategy: + # Do not let a 3.9-only failure mask a 3.13-only failure. + fail-fast: false + matrix: + # "3.9" resolves to 3.9.25 and satisfies ">=3.9.2". The exact patch 3.9.2 + # is NOT pinnable: actions/python-versions ships no 3.9.2 build for + # ubuntu-24.04 (16.04/18.04/20.04 only), so `python-version: "3.9.2"` + # fails to install on ubuntu-latest. + python-version: ["3.9", "3.11", "3.13"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: ${{ matrix.python-version }} - uses: astral-sh/setup-uv@v5 with: enable-cache: true - - run: uv sync --all-extras + # --frozen: fail if uv.lock is out of sync with pyproject.toml rather than + # silently resolving something different from what was reviewed. + - run: uv sync --frozen --all-extras - name: Lint run: uv run ruff check . @@ -33,21 +62,96 @@ jobs: - name: Format run: uv run ruff format --check . - - name: Type check - run: uv run mypy src - # Deterministic tests only. tests/e2e needs OPENROUTER_API_KEY and skips # cleanly without it. - name: Tests + if: matrix.python-version != '3.11' run: uv run pytest tests/unit -q + # Coverage on one leg only: three legs would triple runtime to produce the + # same single number. + # + # Ratchet floor. Coverage may go up, never down — raise this when it rises. + # Lowering it is allowed only with an explicit reason in the PR body, since + # a port run that adds source without tests shows up here first. + - name: Tests with coverage + if: matrix.python-version == '3.11' + run: >- + uv run pytest tests/unit -q + --cov --cov-report=term-missing --cov-report=xml + --cov-fail-under=83 + + types: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - run: uv sync --frozen --all-extras + + - name: Lockfile is in sync with pyproject + run: uv lock --check + + # tests/ included on purpose: CI used to check src only, so every fake + # client and payload builder in tests/ was unverified — exactly where an + # Optional deref makes an assertion silently no-op. Not matrixed because + # [tool.mypy] python_version = "3.9" pins the analysis target, so the + # output is identical on every interpreter. + - name: Type check + run: uv run mypy src tests + + # A package that imports fine from the source tree can still ship a broken + # wheel (missing package data, py.typed, or a module the build excludes). + build: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + # Built on the oldest supported interpreter so a wheel that only imports + # on newer syntax fails here rather than for a user on 3.9. + - uses: actions/setup-python@v5 + with: + python-version: "3.9" + + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Build wheel and sdist + run: uv build --out-dir dist + + # --isolated --no-project: install only the built wheel, with the source + # tree off sys.path, so this proves the artifact rather than the repo. + - name: Import the public API from the built wheel + run: | + set -euo pipefail + wheel=$(ls dist/*.whl) + uv run --isolated --no-project --with "$wheel" python -c " + from openrouter_agent import call_model, OpenRouter, tool, ModelResult + import importlib.metadata as md + print('imported openrouter-agent', md.version('openrouter-agent'))" + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + # Live end-to-end tests against the real OpenRouter API: streaming, a real # tool round, approval pause/resume, lifecycle hooks, state serialization # round-trip. Costs a few cents per run (small model, short prompts). # # Warns and exits 0 when the secret is missing (e.g. PRs from forks, where # GitHub withholds secrets) instead of failing — same pattern as upstream - # typescript-agent's e2e job. + # typescript-agent's e2e job. That is also why it must not be a required check. e2e: runs-on: ubuntu-latest timeout-minutes: 15 @@ -62,7 +166,7 @@ jobs: with: enable-cache: true - - run: uv sync --all-extras + - run: uv sync --frozen --all-extras - name: Live e2e tests env: @@ -74,23 +178,25 @@ jobs: fi uv run pytest tests/e2e -q - # Reports the port's own mechanical gate. Advisory here, BLOCKING inside the - # sync job (scripts/upstream) where it gates whether state.yaml advances. + # The port's own mechanical gate — the same script `scripts/upstream` runs to + # decide whether .upstreamer/state.yaml may advance. + # + # Blocking. It was previously advisory on the premise that the required-API + # check "fails by design until the first sync lands"; that is no longer true — + # the verifier passes with all 31 required symbols exported and 0 failures, so + # advisory would only let that regress silently. # - # Advisory on purpose: the port is currently a minor version behind upstream, so - # the required-API check fails by design until the first sync lands. Making that - # a red required check on every unrelated PR just teaches people to ignore CI. - # The signal still shows up in the job summary. + # It intentionally re-runs ruff/mypy/pytest that `check` also runs: the point is + # to exercise them exactly as the sync pipeline does, so CI and the port gate + # cannot drift apart. verify-port: runs-on: ubuntu-latest timeout-minutes: 15 - if: github.event_name == 'pull_request' steps: - uses: actions/checkout@v4 - uses: ./.github/actions/port-toolchain - - name: Port verifier (advisory) + - name: Port verifier id: verify - continue-on-error: true run: | set -o pipefail ./.upstreamer/scripts/verify.sh 2>&1 | tee /tmp/verify.log @@ -104,8 +210,7 @@ jobs: if [ "${{ steps.verify.outcome }}" = "success" ]; then echo "Port is in sync with its parity floor." else - echo "Parity gaps below. Expected until the port catches up to upstream —" - echo "advisory here, blocking inside the sync job." + echo "Parity floor broken — see the failures below." fi echo echo '```' diff --git a/.gitignore b/.gitignore index 0c2a0f4..456bfcb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,11 @@ __pycache__/ .pytest_cache/ .mypy_cache/ .ruff_cache/ +# Coverage artifacts (CI writes coverage.xml; --cov writes .coverage) +.coverage +.coverage.* +coverage.xml +htmlcov/ dist/ build/ *.egg-info/ diff --git a/.upstreamer/eval.md b/.upstreamer/eval.md index f859116..639ccf6 100644 --- a/.upstreamer/eval.md +++ b/.upstreamer/eval.md @@ -37,6 +37,14 @@ version behind on real behavior. That failure mode is the one to catch. port's own shape rather than upstream behavior are not parity coverage. 5. Prefer reading upstream tests: they encode the behavior contract most precisely. Check the port covers the same cases. +6. Diff the two test suites by file, so an unported upstream test file is visible + rather than inferred: + ```bash + ls tmp/upstreamer/upstream/packages/agent/tests/unit/*.test.ts \ + | sed 's|.*/||;s|\.test\.ts$||;s|-|_|g' | sort > /tmp/up.txt + ls tests/unit/test_*.py | sed 's|.*/test_||;s|\.py$||' | sort > /tmp/port.txt + comm -23 /tmp/up.txt /tmp/port.txt # upstream tests with no Python counterpart + ``` ## Required Qualities @@ -72,6 +80,26 @@ even on no-tools stream error paths. **Compatibility helpers.** Claude/Chat conversion round-trips preserve metadata, reasoning, tool use, and unsupported content. +**Test parity.** Judge the tests as coverage of *upstream* behavior, not as +evidence the port ran. Concretely: + +- Enumerate upstream's test files at the target commit and check each has a Python + counterpart (`foo-bar.test.ts` → `test_foo_bar.py`). List every unported file + with the invariant it protects. Unported tests covering the tool loop, state, + approval/HITL ordering, hooks, or streaming are **FAIL**-worthy; cosmetic or + type-level ones are warnings. +- Read what the new tests assert. A test that would still pass if the port + diverged from upstream is not coverage. Specifically flag: membership-only + assertions on event streams (no order or count), `assert x is not None` as a + test's only assertion, `len(xs) > 0` where the invariant is which items, + vacuous conditional asserts, and "a tool executed" where upstream asserts + **exactly once**. +- Flag any new hand-rolled fake client or partial response dict that bypasses + `tests/_fixtures.py`. Partial stubs omit fields the real API always sends, which + is how a port passes its own suite while mishandling production payloads. +- Confirm the coverage floor in `.github/workflows/ci.yaml` was not lowered. A + lowered floor with no stated reason is a finding. + **Divergences are the documented ones.** Every difference from upstream is either in the contract's Idiomatic Divergences section or recorded as a compatibility note. An undocumented divergence is a finding. diff --git a/.upstreamer/scripts/verify.sh b/.upstreamer/scripts/verify.sh index 84a0302..556eb9a 100755 --- a/.upstreamer/scripts/verify.sh +++ b/.upstreamer/scripts/verify.sh @@ -21,13 +21,23 @@ run() { echo "=== Verification: python-agent ===" echo +# Coverage ratchet. Must match --cov-fail-under in .github/workflows/ci.yaml. +# Raise when coverage rises; never lower it to make a port run pass. +COVERAGE_FLOOR=83 + echo "-- Toolchain" if command -v uv >/dev/null 2>&1; then - run "uv sync" uv sync --all-extras + # --frozen: fail on uv.lock / pyproject.toml drift instead of silently + # resolving something other than what was reviewed. + run "uv sync" uv sync --frozen --all-extras + run "lockfile in sync" uv lock --check run "ruff check" uv run ruff check . run "ruff format" uv run ruff format --check . - run "mypy" uv run mypy src - run "pytest" uv run pytest tests/unit -q + # tests included: a fake client or payload builder with an unchecked Optional + # deref is exactly how an assertion silently stops asserting. + run "mypy" uv run mypy src tests + run "pytest + coverage floor ($COVERAGE_FLOOR%)" \ + uv run pytest tests/unit -q --cov --cov-fail-under="$COVERAGE_FLOOR" else fail "uv not installed (required to build and test this package)" fi @@ -83,6 +93,33 @@ else fi echo +# The suite is what makes "a version behind on real behavior" visible or +# invisible, so the file-level mapping is mechanically checked. Advisory: which +# gaps are acceptable is a judgment call, and .upstreamer/eval.md makes it. This +# just ensures nobody has to notice the gap on their own. +echo "-- Test parity with upstream (advisory)" +upstream_tests="tmp/upstreamer/upstream/packages/agent/tests/unit" +if [ -d "$upstream_tests" ]; then + unported="" + for ts in "$upstream_tests"/*.test.ts; do + [ -e "$ts" ] || continue + base=$(basename "$ts" .test.ts | tr '-' '_') + [ -f "tests/unit/test_${base}.py" ] || unported="$unported ${base}" + done + if [ -z "${unported// /}" ]; then + pass "every upstream tests/unit file has a Python counterpart" + else + count=$(printf '%s' "$unported" | wc -w | tr -d ' ') + echo " NOTE: $count upstream test file(s) have no tests/unit counterpart:" + for name in $unported; do echo " $name.test.ts -> tests/unit/test_$name.py"; done + echo " Not a mechanical failure — see the Test Parity section of" + echo " .upstreamer/upstreamer.md and let the eval judge severity." + fi +else + echo " SKIP: no upstream checkout — test parity unchecked" +fi +echo + echo "-- No leaked TypeScript artifacts" leaked=$(find src tests -type f \( -name '*.ts' -o -name '*.js' -o -name 'package.json' \ -o -name 'tsconfig*.json' -o -name 'pnpm-lock.yaml' \) 2>/dev/null) diff --git a/.upstreamer/skills/port-test-quality/SKILL.md b/.upstreamer/skills/port-test-quality/SKILL.md new file mode 100644 index 0000000..ef09648 --- /dev/null +++ b/.upstreamer/skills/port-test-quality/SKILL.md @@ -0,0 +1,175 @@ +--- +name: port-test-quality +description: Port upstream tests and keep the Python suite an honest check on upstream behavior. Use when a sync adds or changes tests, when coverage drops below the floor, when writing a test for newly ported behavior, or when reviewing whether a port's tests actually assert parity. +--- + +# Port Test Quality + +Execution discipline for the test half of the port. The binding rules live in the +**Test Parity** section of `.upstreamer/upstreamer.md`; this skill is how to +satisfy them. If the two conflict, the contract wins — report the conflict. + +## Why this exists + +A port can compile, export every required symbol, pass its own test suite, and +still be a version behind on real behavior. The suite is what makes that failure +mode visible or invisible, so tests are part of the port, not a follow-up. + +The concrete history: this repo once had 15 test files against upstream's 46 at the +same commit. All 104 tests passed. Three upstream invariants had no counterpart — +`turn.end` dropped by a broadcaster race, a tool executing twice per round, and a +mixed auto+manual round sending an orphaned `function_call` that providers reject +with a 400. Every one of those bugs could have been present with CI fully green. + +## Step 1: Diff the suites before writing anything + +Never infer coverage. Enumerate it: + +```bash +ls tmp/upstreamer/upstream/packages/agent/tests/unit/*.test.ts \ + | sed 's|.*/||;s|\.test\.ts$||;s|-|_|g' | sort > /tmp/up.txt +ls tests/unit/test_*.py | sed 's|.*/test_||;s|\.py$||' | sort > /tmp/port.txt +comm -23 /tmp/up.txt /tmp/port.txt # upstream tests with no Python counterpart +``` + +Also diff the delta's test changes directly, since a *changed* upstream test is as +load-bearing as a new one: + +```bash +git -C tmp/upstreamer/upstream diff .. -- packages/agent/tests +``` + +Maintain the 1:1 mapping: `foo-bar.test.ts` → `tests/unit/test_foo_bar.py`. That +mapping is what makes this diff meaningful; breaking it hides gaps. + +## Step 2: Triage by what breaks in production + +Port highest-severity first. Rank by blast radius, not by file size: + +| Severity | Area | Why | +| --- | --- | --- | +| HIGH | Tool loop: exactly-once execution, mixed auto+manual rounds, turn boundaries | Double-executes a side-effecting tool, or emits a request the provider rejects | +| HIGH | Approval / HITL ordering | Auto-tool output lost before a pause; wrong resume order | +| HIGH | State serialization, version mismatch | Silent data corruption across a resume | +| MEDIUM | Hooks firing/ordering, session-id threading, `SessionEnd` on error paths | Telemetry and permission gates silently stop working | +| MEDIUM | Streaming fan-out, error propagation to every consumer | A consumer hangs or ends silently | +| LOW | Compat round-trips, schema sanitization breadth, type-level tests | Recoverable, usually a rejected request | + +Report anything you leave unported, with its invariant and severity. An +unreported gap is how the next run skips past it. + +## Step 3: Write the test against upstream behavior + +Read the upstream test and port **the invariant**, not the syntax. + +**Assert order and count, never mere membership.** + +```python +# NO — passes if turn.end fires twice, or before turn.start +assert "turn.end" in [event["type"] for event in events] + +# YES +assert types.count("turn.end") == 1 +assert types.index("turn.start") < types.index("turn.end") +``` + +**Assert exactly-once, not that-it-happened.** Use a counter object, not +`lambda p, c: calls.append(...) or {...}` — `list.append` returns `None`, so the +lambda both trips mypy's `func-returns-value` and can silently return the wrong +tool output. See `tests/unit/test_tool_execution_once.py`. + +**Assert request counts.** "No follow-up request was sent" is a real invariant; +`len(client.requests) == 1` is how you state it. `QueuedResponses` raises a +descriptive `AssertionError` on queue exhaustion precisely so an unexpected extra +turn names itself. + +**Rejectable patterns** — each looks like coverage and is not: + +- `assert x is not None` as a test's only assertion (fine as a mypy-narrowing line + before a real one) +- `assert len(xs) > 0` where the invariant is *which* items are present +- `assert v is None if "k" in d else True` — `assert True` on the missing branch +- Re-asserting a stub's own canned data, or that a type guard accepts an object + built with that guard's marker + +## Step 4: Use the shared fixtures + +Import from `tests/_fixtures.py`; do not hand-roll fake clients or partial +response dicts. + +```python +from tests._fixtures import QueuedClient, function_call_item, make_response, text_response +``` + +`make_response` populates every field the real Responses API returns (`status`, +`model`, `object`, `created_at`, `tool_choice`, …). Partial stubs are how a port +passes its own suite while mishandling production payloads — a stub must never be +more forgiving than the API. + +Builders emit upstream's **camelCase** wire shape (`callId`) because that is what +the port's internals consume. `assert_matches_sdk_response_shape` converts and +validates against the generated SDK's model, so a required-field change in the SDK +fails loudly instead of drifting. + +If a test needs bespoke transport behavior (error injection, streaming, conditional +branching), keep the bespoke class but build its payloads with these builders. + +## Step 5: Keep it deterministic + +- **Never port a timing race as `asyncio.sleep`.** Upstream races a 20ms stream + tick against a 5ms executor; as wall-clock timing it flips under CI load. Gate + on `asyncio.Event` so ordering is explicit — see + `tests/unit/test_turn_end_race_condition.py`. +- **Construct `asyncio` primitives inside the async test body.** + `asyncio.Condition()` binds the running loop eagerly on 3.9 and lazily on 3.13, + so module-scope construction fails on 3.9 only — and CI now tests 3.9. +- **Do not assert on pending-task counts.** `ToolEventBroadcaster._wake()` fires + `create_task(self._notify())` and never awaits it, leaving orphan tasks by + design. +- Prove it: `for i in $(seq 1 50); do uv run pytest -q || break; done`. + +## Step 6: Prefer the public API over private internals + +Upstream tests sometimes cast to an internal type and call a private method. Where +this port's internals differ, drive the same invariant through `call_model`. + +Known case: upstream calls `executeToolsIfNeeded()`; this port has no such method +— the tool loop is inlined in `ModelResult._run`. Driving `call_model` gives the +identical assertion and survives the next sync's refactors. + +## Step 7: Document deliberate divergences at the assertion + +Where the port must assert differently from upstream, say why with a file +reference, or a later reader "fixes" it back: + +```python +# Upstream asserts `callId`. This port emits snake_case `call_id`: _send +# normalizes at the transport boundary (model_result.py:148-156). Deliberate — +# do not change back to callId, it will fail. +assert outputs[0]["call_id"] == "call_auto_1" +``` + +If you derive a new divergence, add it to the contract's Test Parity section as +well as the assertion. + +## Step 8: Verify before handing off + +```bash +uv run pytest tests/unit -q # all green +uv run pytest tests/unit -q --cov --cov-fail-under= # floor holds +uv run mypy src tests # tests are checked too +uv run ruff format . && uv run ruff check . +.upstreamer/scripts/verify.sh +``` + +The coverage floor in `.github/workflows/ci.yaml` is a **ratchet**: raise it when +coverage rises, never lower it. If newly ported source drops coverage below the +floor, the missing tests are part of the port — write them rather than lowering the +number. + +## Report + +In the final port report, include: upstream test files ported; upstream test files +**not** ported with invariant and severity; new tests added and the invariant each +pins; any new divergence documented; coverage before and after; and whether the +floor moved. diff --git a/.upstreamer/skills/upstreamer-converter/SKILL.md b/.upstreamer/skills/upstreamer-converter/SKILL.md index 6274a8f..cdaf3d4 100644 --- a/.upstreamer/skills/upstreamer-converter/SKILL.md +++ b/.upstreamer/skills/upstreamer-converter/SKILL.md @@ -78,13 +78,34 @@ substrate-pin section directs it. ## Step 4: Tests -Ported behavior without a test proves nothing. For every behavioral change: - -1. Add or update deterministic tests in this repo's existing test layout and style. +Ported behavior without a test proves nothing — and upstream's own test suite is +the most precise statement of the behavior contract that exists, so **upstream +tests are part of the port**, not a follow-up. + +Follow the `port-test-quality` skill at +@.upstreamer/skills/port-test-quality/SKILL.md for the full procedure. It covers +diffing the two suites by file, severity triage, the shared fixtures in +`tests/_fixtures.py`, determinism rules, and the assertion patterns that are +rejected. The binding rules are the **Test Parity** section of +`.upstreamer/upstreamer.md`. + +The short version: + +1. Diff upstream's test files against `tests/` before writing anything, and + maintain the 1:1 mapping `foo-bar.test.ts` → `tests/unit/test_foo_bar.py`. + Report every upstream test file left unported, with its invariant and severity. 2. Cover the specific upstream behavior that changed, not just the happy path. Upstream fixes are usually edge cases — that edge case is the test. -3. Tests must pass without network access or paid credentials. Live/e2e tests - must skip cleanly when credentials are absent. +3. Assert upstream behavior, not the port's own shape: order and count over + membership, exactly-once over "it ran", request counts where "no follow-up was + sent" is the invariant. +4. Build payloads with `tests/_fixtures.py`; never hand-roll a partial response + dict or a new fake client. +5. Tests must pass without network access or paid credentials, and be deterministic + — gate on `asyncio.Event`, never on `asyncio.sleep`. Live/e2e tests must skip + cleanly when credentials are absent. +6. The coverage floor is a ratchet. Raise it when coverage rises; never lower it to + make a run pass. ## Step 5: Mechanical verification diff --git a/.upstreamer/upstreamer.md b/.upstreamer/upstreamer.md index 90191cf..4cb47bb 100644 --- a/.upstreamer/upstreamer.md +++ b/.upstreamer/upstreamer.md @@ -177,10 +177,78 @@ Module naming follows the existing layout: upstream `lib/tool-executor.ts` → `src/openrouter_agent/tool_executor.py`. Keep that correspondence for new modules so the port stays navigable against the reference. +## Test Parity + +Upstream's test suite is the most precise statement of the behavior contract that +exists. Porting the source without porting the tests produces a package that +compiles, passes its own assertions, and is a version behind on real behavior — +the exact failure this pipeline exists to prevent. So tests are in scope, not a +nice-to-have. + +**Upstream tests are part of the port.** + +- Maintain a 1:1 file mapping: upstream `tests/unit/foo-bar.test.ts` → + `tests/unit/test_foo_bar.py`. When a sync touches an upstream test file, port + the corresponding cases in the same run. +- When upstream **adds** a test file, port it. When upstream **changes** + assertions in one, update the Python counterpart to match. An upstream test + file with no Python counterpart is a parity gap — report it explicitly in the + final report, with the invariant it protects and a severity assessment. +- A behavioral change ported without a test asserting it is incomplete work. + +**Assert upstream behavior, not the port's own shape.** These are the recurring +ways a test looks like coverage without being coverage. All are rejectable: + +- Membership-only assertions on event streams. Assert **order and count**: + `types.count("turn.end") == 1` and `types.index(...) < types.index(...)`, not + `"turn.end" in types`. A membership check passes when an event fires twice, + fires out of order, or carries the wrong payload. +- Vacuous conditional asserts. `assert x is None if "x" in d else True` is + `assert True` on the missing branch. +- `assert x is not None` as a test's *only* assertion. As a mypy-narrowing line + before a real assertion it is fine; alone it asserts almost nothing. +- `assert len(xs) > 0` where the invariant is *which* items are present. +- Re-asserting a stub's own canned data, or that a type guard returns True for an + object the test built with that guard's marker. +- Asserting an execution *happened* when the invariant is that it happened + **exactly once**. Double-execution of a side-effecting tool is a real upstream + regression class; only a count catches it. + +**Use the shared fixtures.** `tests/_fixtures.py` provides `make_response`, +`function_call_item`, `text_response`, `tool_call_response`, `QueuedClient`, and +`MemoryStateAccessor`. Do not hand-roll new fake clients or partial response +dicts: `make_response` populates every field the real Responses API returns, so a +stub cannot be more forgiving than production. If a test needs bespoke transport +behavior (error injection, streaming), build its payloads with these builders. + +**Coverage may not decay.** `--cov-fail-under` in `.github/workflows/ci.yaml` is a +ratchet. A port run may raise it, never lower it. If new ported source drops +coverage below the floor, the missing tests are part of the port — write them. + +**Comment deliberate divergences at the assertion.** Where the port must assert +something different from upstream, say why with a source reference. Known case: +outgoing `function_call_output` items use snake_case `call_id`, not upstream's +`callId`, because `ModelResult._send` normalizes at the transport boundary +(`model_result.py:148-156`). Without the comment, a later reader "fixes" it back +and breaks the test. + +**Keep tests deterministic.** Never port a timing race as `asyncio.sleep`. Gate on +`asyncio.Event` so ordering is explicit — see +`tests/unit/test_turn_end_race_condition.py`. Construct `asyncio` primitives +inside the async test body: `asyncio.Condition()` binds the running loop eagerly +on 3.9 and lazily on 3.13, so module-scope construction breaks on 3.9 only. + +**Prefer the public API over private internals.** Upstream tests sometimes cast to +an internal type and call a private method. Where this port's internals differ +(e.g. it has no `_execute_tools_if_needed` — the loop is inlined in +`ModelResult._run`), drive the same invariant through `call_model` instead. The +test then survives the next sync's refactors. + ## Verification -`.upstreamer/scripts/verify.sh` must pass: `ruff`, `mypy`, `pytest`, plus the -required-public-API presence check and no-TS-artifact check. +`.upstreamer/scripts/verify.sh` must pass: `ruff`, `mypy` (over `src` **and** +`tests`), `pytest`, the coverage floor, plus the required-public-API presence +check and no-TS-artifact check. Then `.upstreamer/eval.md` must return PASS or PASS WITH WARNINGS from a fresh review context before state advances. diff --git a/PORTING.md b/PORTING.md index fb4db6f..3cbc132 100644 --- a/PORTING.md +++ b/PORTING.md @@ -51,13 +51,16 @@ holds. | `.upstreamer/eval.md` | Parity gate: fresh-context behavioral review. | | `.upstreamer/eval-report.md` | Latest eval result, or a bankruptcy report. | | `.upstreamer/skills/upstreamer-converter/` | Execution discipline for the porting agent. | +| `.upstreamer/skills/port-test-quality/` | How to port upstream tests and keep coverage honest. | | `.upstreamer/port.env` | Local secrets. **Gitignored.** | | `scripts/upstream` | The wrapper. | ## Two gates, and why state matters -**Mechanical** (`verify.sh`) — objective: does it build, lint, type-check, pass -tests, and export every symbol the contract requires. +**Mechanical** (`verify.sh`) — objective: does it build, lint, type-check (`src` +*and* `tests`), pass tests, hold the coverage floor, and export every symbol the +contract requires. It also reports which upstream test files have no Python +counterpart — advisory, since severity is the eval's call. **Parity** (`eval.md`) — judgment, run in a fresh context that reads the upstream reference directly: does it actually *behave* like upstream. This is the gate that @@ -113,5 +116,11 @@ Review it as a *port*, not a normal diff: 2. Read the upstream delta yourself for anything load-bearing — the tool loop, state serialization, approval/HITL ordering, hooks, streaming. 3. Confirm new tests assert *upstream behavior*, not merely the port's own shape. + The contract's Test Parity section lists the patterns that look like coverage + and are not — membership-only stream assertions, `assert x is not None` as a + test's only assertion, "a tool ran" where the invariant is *exactly once*. + Check the verifier's test-parity note for upstream test files left unported. 4. Any new naming mapping the run derived should be promoted into the contract's naming table. +5. Check the coverage floor did not move down. It is a ratchet; lowering it needs + a stated reason. diff --git a/README.md b/README.md index 7d9e1b9..ec0b7ae 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,35 @@ When a stop condition fires while the model is still emitting tool calls, `call_ Use `from_claude_messages` / `to_claude_message` for Anthropic-style messages and `from_chat_messages` / `to_chat_message` for OpenAI Chat-style messages. Content that cannot be represented directly is carried as `unsupported_content` instead of being silently discarded. +## Development + +```bash +uv sync --frozen --all-extras + +uv run pytest tests/unit -q # deterministic suite +uv run pytest tests/unit --cov # with coverage +uv run mypy src tests # types, tests included +uv run ruff check . && uv run ruff format --check . +``` + +`tests/e2e/` runs against the live OpenRouter API and skips cleanly without +`OPENROUTER_API_KEY`: + +```bash +OPENROUTER_API_KEY=sk-or-... uv run pytest tests/e2e -q +``` + +Tests share fixtures from `tests/_fixtures.py` — `make_response`, +`function_call_item`, `text_response`, `tool_call_response`, `QueuedClient`, +`MemoryStateAccessor`. Use them instead of hand-rolling a fake client: +`make_response` populates every field the real Responses API returns, so a stub +cannot be more permissive than production. + +CI runs the suite on Python 3.9, 3.11, and 3.13, type-checks `src` and `tests`, +enforces a coverage floor, and verifies the built wheel imports in isolation. +Because this package is a port, tests are held to upstream behavior — see the +Test Parity section of `.upstreamer/upstreamer.md` and [PORTING.md](PORTING.md). + ## Parity Notes This is a faithful Python port of the `@openrouter/agent` public surface, with Python-native names (`call_model`, `server_tool`, `get_text_stream`) and Pydantic v2 schemas in place of Zod. Runtime behavior is preserved where the Python SDK exposes matching Responses API types. Static type inference is necessarily looser than TypeScript conditional types; the package ships `py.typed`, `Protocol`/dataclass aliases, and clear runtime validation rather than pretending to reproduce TypeScript tuple inference exactly. diff --git a/pyproject.toml b/pyproject.toml index babd814..95af7a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dev = [ "mypy>=1.10,<2.0", "pytest>=8.4,<9.0", "pytest-asyncio>=0.23,<1.0", + "pytest-cov>=5.0,<8.0", "ruff>=0.14", ] @@ -38,6 +39,34 @@ ignore = ["B008", "B009", "B010", "B023", "B904", "E501", "I001", "UP006", "UP00 [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] +# Coverage is deliberately NOT in addopts: it would also apply to +# `pytest tests/e2e`, where a src-wide floor is meaningless and would fail the +# e2e job. The floor is passed explicitly by the unit-test CI step. +addopts = ["--strict-markers", "--strict-config", "-ra"] +filterwarnings = [ + "default", + "error::DeprecationWarning", + # The generated `openrouter` SDK leaves event loops and httpx sockets + # unclosed at interpreter teardown. A blanket `error` turns that SDK-side + # cleanup into a suite failure that says nothing about this package. + "default::ResourceWarning", +] + +[tool.coverage.run] +# Match the measured floor: statement coverage, no branch coverage. +source = ["openrouter_agent"] + +[tool.coverage.report] +show_missing = true +exclude_also = ["if TYPE_CHECKING:", "raise NotImplementedError", "@overload"] +# Modules ported for 1:1 parity with upstream that this port does not route +# through (it inlines their logic). They are kept so the next sync has a stable +# target — see the docstring in each. Excluded so they do not dilute the floor. +omit = [ + "*/openrouter_agent/stream_type_guards.py", + "*/openrouter_agent/tool_orchestrator.py", + "*/openrouter_agent/api_shape_helpers/claude_message.py", +] [tool.mypy] python_version = "3.9" @@ -50,3 +79,11 @@ mypy_path = "src" [[tool.mypy.overrides]] module = ["anyio.*"] follow_imports = "skip" + +[[tool.mypy.overrides]] +module = ["tests.*"] +# `tool()` in the ported source returns Dict[str, Any] rather than a +# ClientTool/ServerTool union, so every tests-side call site trips list-item / +# arg-type. Fixing tool.py is the real answer, but it is ported source the next +# sync regenerates — suppress narrowly here instead of contorting the tests. +disable_error_code = ["list-item", "arg-type"] diff --git a/src/openrouter_agent/api_shape_helpers/claude_message.py b/src/openrouter_agent/api_shape_helpers/claude_message.py index 18d864c..0f95dd4 100644 --- a/src/openrouter_agent/api_shape_helpers/claude_message.py +++ b/src/openrouter_agent/api_shape_helpers/claude_message.py @@ -1,3 +1,16 @@ +"""Claude message shape types. + +Ported for 1:1 module parity with upstream `api-shape-helpers/claude-message.ts`. +Upstream imports these types into `anthropic-compat`, `claude-type-guards`, and +`stream-transformers`; this port declares the equivalent shapes inline in +`anthropic_compat.py`, so nothing here is currently reachable. + +Kept deliberately: the porting contract (`.upstreamer/upstreamer.md`) requires one +Python module per upstream module, so deleting this would be a parity regression +that the next sync re-creates. Excluded from the coverage floor in +`pyproject.toml` rather than deleted. Do not re-litigate. +""" + from __future__ import annotations from typing import Any, Dict, List, TypedDict, Union diff --git a/src/openrouter_agent/stream_type_guards.py b/src/openrouter_agent/stream_type_guards.py index 36402ca..11e6e7a 100644 --- a/src/openrouter_agent/stream_type_guards.py +++ b/src/openrouter_agent/stream_type_guards.py @@ -1,3 +1,16 @@ +"""Stream item type guards. + +Ported for 1:1 module parity with upstream `lib/stream-type-guards.ts`. Upstream +routes `model-result`, `tool-executor`, and `stream-transformers` through these +predicates; this port inlines the equivalent checks in `stream_transformers.py`, +so nothing here is currently reachable. + +Kept deliberately: the porting contract (`.upstreamer/upstreamer.md`) requires one +Python module per upstream lib module, so deleting this would be a parity +regression that the next sync re-creates. Excluded from the coverage floor in +`pyproject.toml` rather than deleted. Do not re-litigate. +""" + from __future__ import annotations from typing import Any, Mapping, Optional diff --git a/src/openrouter_agent/tool_orchestrator.py b/src/openrouter_agent/tool_orchestrator.py index b6ef061..f855a77 100644 --- a/src/openrouter_agent/tool_orchestrator.py +++ b/src/openrouter_agent/tool_orchestrator.py @@ -1,3 +1,15 @@ +"""Tool orchestration re-exports. + +Ported for 1:1 module parity with upstream `lib/tool-orchestrator.ts`, which is +itself unreferenced upstream. This port drives tool execution from the inlined +loop in `ModelResult._run`. + +Kept deliberately: the porting contract (`.upstreamer/upstreamer.md`) requires one +Python module per upstream lib module, so deleting this would be a parity +regression that the next sync re-creates. Excluded from the coverage floor in +`pyproject.toml` rather than deleted. Do not re-litigate. +""" + from __future__ import annotations from .model_result import ModelResult diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..90a7fa2 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,6 @@ +"""Test package. + +Present so `tests._fixtures` is importable as a module from test files. Without +it, `from tests._fixtures import ...` fails under pytest's default rootdir +handling and every test file falls back to hand-rolled stubs. +""" diff --git a/tests/_fixtures.py b/tests/_fixtures.py new file mode 100644 index 0000000..dd1f3c1 --- /dev/null +++ b/tests/_fixtures.py @@ -0,0 +1,204 @@ +"""Shared test fixtures: canonical fake client and Responses-API payload builders. + +Why this module exists +---------------------- +Before it, 11 of 14 test files hand-copied their own `QueuedResponses` / +`QueuedClient` stubs (6 of them byte-identical) plus their own +`function_call_item` / `text_response` helpers. Those copies populated only `id` +and `output`, omitting every other field a real Responses API result carries +(`status`, `model`, `object`, `created_at`, `error`, `tool_choice`, ...). A port +that mishandled — or silently depended on the absence of — any omitted field +passed the whole suite. + +`make_response` populates the complete required field set instead, so a stub can +no longer be more forgiving than the real API. + +Casing: builders emit upstream's **camelCase** wire shape (`callId`, `createdAt`) +because that is what the port's internals consume; `ModelResult._send` normalizes +to the generated SDK's snake_case at the transport boundary only +(`model_result.py:148-156`). Building these from the SDK's own models would emit +snake_case and exercise only the fallback path — the opposite of what these tests +need to pin. `assert_matches_sdk_response_shape` bridges the two: it converts and +validates, so the *field set* still breaks loudly if the SDK changes. +""" + +from __future__ import annotations + +import re +from typing import Any, Dict, List, Mapping, Optional + +from openrouter_agent.tool_types import ConversationState + +# Every field `openrouter.components.OpenResponsesResult` marks required, with +# neutral defaults. Keep in sync with assert_matches_sdk_response_shape below — +# that helper is what fails when the SDK's required set drifts from this one. +_REQUIRED_RESPONSE_DEFAULTS: Dict[str, Any] = { + "object": "response", + "createdAt": 0, + "completedAt": 0, + "status": "completed", + "model": "test-model-v1", + "error": None, + "incompleteDetails": None, + "instructions": None, + "metadata": None, + "frequencyPenalty": None, + "presencePenalty": None, + "temperature": None, + "topP": None, + "toolChoice": "auto", + "tools": [], + "parallelToolCalls": False, +} + + +def make_response( + response_id: str, + output: List[Dict[str, Any]], + *, + usage: Any = None, + **overrides: Any, +) -> Dict[str, Any]: + """Build a complete Responses-API result. + + Pass `**overrides` to express a non-default field (e.g. `status="incomplete"`) + rather than hand-rolling a partial dict. + """ + response: Dict[str, Any] = {"id": response_id, "output": list(output)} + response.update(_REQUIRED_RESPONSE_DEFAULTS) + if usage is not None: + response["usage"] = usage + response.update(overrides) + return response + + +def function_call_item(call_id: str, name: str, arguments: str = "{}") -> Dict[str, Any]: + """A `function_call` output item, in upstream's camelCase `callId` shape.""" + return { + "type": "function_call", + "id": f"fc_{call_id}", + "callId": call_id, + "name": name, + "arguments": arguments, + "status": "completed", + } + + +def message_item(text: str, *, role: str = "assistant", item_id: str = "msg_1") -> Dict[str, Any]: + return { + "type": "message", + "id": item_id, + "role": role, + "status": "completed", + "content": [{"type": "output_text", "text": text}], + } + + +def text_response(response_id: str, text: str, usage: Any = None, **overrides: Any) -> Dict[str, Any]: + """A terminal assistant text response.""" + return make_response(response_id, [message_item(text, item_id=f"msg_{response_id}")], usage=usage, **overrides) + + +def tool_call_response( + response_id: str, + name: str = "echo", + *, + call_id: Optional[str] = None, + arguments: str = "{}", + usage: Any = None, + **overrides: Any, +) -> Dict[str, Any]: + """A response whose single output item is one function call.""" + return make_response( + response_id, + [function_call_item(call_id or f"call_{response_id}", name, arguments)], + usage=usage, + **overrides, + ) + + +def usage_block(**overrides: Any) -> Dict[str, Any]: + base = {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150, "cost": 0.002} + base.update(overrides) + return base + + +class QueuedResponses: + """Fake `client.beta.responses` returning queued payloads in order. + + Records every request in `.requests` so tests can assert the request *count* + and the exact follow-up input — several upstream invariants are literally + "no follow-up request was made". + """ + + def __init__(self, responses: List[Dict[str, Any]]) -> None: + self._responses = list(responses) + self.requests: List[Dict[str, Any]] = [] + + async def send_async(self, **kwargs: Any) -> Any: + self.requests.append(kwargs) + if not self._responses: + # A bare IndexError here reads as a fixture bug. It is usually the + # actual finding: the port sent a turn the test did not expect. + raise AssertionError( + f"the port sent {len(self.requests)} request(s) but only " + f"{len(self.requests) - 1} response(s) were queued; " + "an unexpected extra turn was requested" + ) + return self._responses.pop(0) + + +class QueuedClient: + """Minimal stand-in for `OpenRouter` exposing `.beta.responses.send_async`.""" + + def __init__(self, responses: List[Dict[str, Any]]) -> None: + self.responses = QueuedResponses(responses) + self.beta = type("Beta", (), {"responses": self.responses})() + + @property + def requests(self) -> List[Dict[str, Any]]: + """Requests the port sent, so tests need not reach through `.beta`.""" + return self.responses.requests + + +class MemoryStateAccessor: + """In-memory `state` accessor. `saved` keeps every version for ordering checks.""" + + def __init__(self) -> None: + self.stored: Optional[ConversationState] = None + self.saved: List[ConversationState] = [] + + async def load(self) -> Optional[ConversationState]: + return self.stored + + async def save(self, state: ConversationState) -> None: + self.stored = state + self.saved.append(state) + + +def _camel_to_snake(name: str) -> str: + return re.sub(r"(? None: + """Validate a builder payload against the generated SDK's own response model. + + The builders emit camelCase (what the port consumes); the SDK model is + snake_case. This converts and validates, so if the SDK adds or renames a + required field, `_REQUIRED_RESPONSE_DEFAULTS` fails loudly here instead of + tests quietly drifting onto a payload shape the real API never produces. + """ + from openrouter.components import OpenResponsesResult + + payload = _to_snake_deep(response) + payload.pop("usage", None) # optional, and its own nested model + OpenResponsesResult.model_validate(payload) + + +def _to_snake_deep(value: Any) -> Any: + """Recursively snake_case every key, so nested output items convert too.""" + if isinstance(value, Mapping): + return {_camel_to_snake(key): _to_snake_deep(item) for key, item in value.items()} + if isinstance(value, list): + return [_to_snake_deep(item) for item in value] + return value diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..140a3e3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +"""Pytest configuration. + +The builders live in `tests/_fixtures.py` rather than here because `conftest.py` +is not importable as a module, and several test files construct payloads and tools +at module scope. Import them directly: + + from tests._fixtures import QueuedClient, text_response, tool_call_response + +They are re-exported below so `conftest` stays the single discovery point. +""" + +from __future__ import annotations + +from tests._fixtures import ( # noqa: F401 (re-exported for discoverability) + MemoryStateAccessor, + QueuedClient, + QueuedResponses, + assert_matches_sdk_response_shape, + function_call_item, + make_response, + message_item, + text_response, + tool_call_response, + usage_block, +) diff --git a/tests/e2e/test_live_call_model.py b/tests/e2e/test_live_call_model.py index ce2c6cb..4f00901 100644 --- a/tests/e2e/test_live_call_model.py +++ b/tests/e2e/test_live_call_model.py @@ -14,6 +14,7 @@ import json import os +from typing import Any, Dict, List import pytest @@ -107,12 +108,16 @@ async def test_live_approval_pause_and_resume_across_calls() -> None: executed = [] + def delete_record(params, ctx): + executed.append(params) + return {"deleted": True} + delete_tool = tool( name="delete_record", description="Deletes the record. Requires approval.", input_schema=dict, output_schema=dict, - execute=lambda params, ctx: executed.append(params) or {"deleted": True}, + execute=delete_record, require_approval=True, ) @@ -154,6 +159,7 @@ async def test_live_approval_pause_and_resume_across_calls() -> None: text = await resumed.get_text() assert len(executed) == 1, "approved tool did not execute exactly once" + assert state.current is not None assert state.current.status == "complete" assert isinstance(text, str) and text.strip() @@ -163,8 +169,8 @@ async def test_live_hooks_fire_on_real_traffic() -> None: all fire during a live tool round, and SessionEnd reports real usage.""" from openrouter_agent import HookEntry, HookName, HooksManager, call_model, tool - fired = [] - usage_totals = {} + fired: List[str] = [] + usage_totals: Dict[str, Any] = {} manager = HooksManager() for hook_name in ( @@ -173,10 +179,12 @@ async def test_live_hooks_fire_on_real_traffic() -> None: HookName.PostToolUse, HookName.PostModelCall, ): - manager.on( - hook_name.value, - HookEntry(handler=lambda payload, ctx, _n=hook_name.value: fired.append(_n) or {}), - ) + + def record(payload: Any, ctx: Any, _n: str = hook_name.value) -> Dict[str, Any]: + fired.append(_n) + return {} + + manager.on(hook_name.value, HookEntry(handler=record)) def session_end(payload, ctx): fired.append(HookName.SessionEnd.value) @@ -226,12 +234,17 @@ async def test_live_state_serialization_round_trip_resumes() -> None: ) executed = [] + + def launch(params, ctx): + executed.append(1) + return {"launched": True} + approve_tool = tool( name="launch", description="Launches the rocket. Requires approval.", input_schema=dict, output_schema=dict, - execute=lambda params, ctx: executed.append(1) or {"launched": True}, + execute=launch, require_approval=True, ) @@ -247,6 +260,7 @@ async def test_live_state_serialization_round_trip_resumes() -> None: }, ) await first.get_response() + assert state.current is not None assert state.current.status == "awaiting_approval" pending = await first.get_pending_tool_calls() @@ -269,4 +283,5 @@ async def test_live_state_serialization_round_trip_resumes() -> None: await resumed.get_text() assert executed == [1] + assert restored.current is not None assert restored.current.status == "complete" diff --git a/tests/unit/test_allow_final_response.py b/tests/unit/test_allow_final_response.py index 3eec26c..ec89a31 100644 --- a/tests/unit/test_allow_final_response.py +++ b/tests/unit/test_allow_final_response.py @@ -1,39 +1,16 @@ from __future__ import annotations -from typing import Any, Dict, List +from typing import Any, Dict from openrouter_agent import call_model, step_count_is, tool from openrouter_agent.model_result import DEFAULT_FINAL_RESPONSE_DIRECTIVE - - -class QueuedResponses: - def __init__(self, responses: List[Dict[str, Any]]) -> None: - self._responses = list(responses) - self.requests: List[Dict[str, Any]] = [] - - async def send_async(self, **kwargs: Any) -> Any: - self.requests.append(kwargs) - return self._responses.pop(0) - - -class QueuedClient: - def __init__(self, responses: List[Dict[str, Any]]) -> None: - self.beta = type("Beta", (), {"responses": QueuedResponses(responses)})() - - -def function_call_item(call_id: str, name: str, arguments: str) -> Dict[str, Any]: - return {"type": "function_call", "id": f"fc_{call_id}", "callId": call_id, "name": name, "arguments": arguments} - - -def text_response(response_id: str, text: str) -> Dict[str, Any]: - return { - "id": response_id, - "output": [{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": text}]}], - } +from tests._fixtures import QueuedClient, text_response +from tests._fixtures import tool_call_response as _tool_call_response def tool_call_response(response_id: str) -> Dict[str, Any]: - return {"id": response_id, "output": [function_call_item("call_weather", "get_weather", '{"city":"nyc"}')]} + """The one weather call every test in this file queues as its first turn.""" + return _tool_call_response(response_id, "get_weather", call_id="call_weather", arguments='{"city":"nyc"}') weather_tool = tool( @@ -59,7 +36,7 @@ async def test_bare_true_appends_default_directive() -> None: ).get_text() assert text == "Final summary." - second_request = client.beta.responses.requests[1] + second_request = client.requests[1] assert "tools" in second_request assert second_request["tool_choice"] == "none" last_item = second_request["input"][-1] @@ -81,7 +58,7 @@ async def test_omitted_allow_final_response_defaults_to_enabled_with_directive() ).get_text() assert text == "Final summary." - second_request = client.beta.responses.requests[1] + second_request = client.requests[1] assert second_request["tool_choice"] == "none" assert second_request["input"][-1] == {"role": "user", "content": DEFAULT_FINAL_RESPONSE_DIRECTIVE} @@ -100,7 +77,7 @@ async def test_non_empty_string_overrides_directive() -> None: }, ).get_text() - second_request = client.beta.responses.requests[1] + second_request = client.requests[1] assert second_request["input"][-1] == {"role": "user", "content": "Summarize now."} @@ -118,7 +95,7 @@ async def test_empty_string_appends_no_message() -> None: }, ).get_text() - second_request = client.beta.responses.requests[1] + second_request = client.requests[1] last_item = second_request["input"][-1] assert last_item.get("type") == "function_call_output" @@ -139,4 +116,4 @@ async def test_false_disables_the_final_turn_entirely() -> None: response = await result.get_response() assert response["id"] == "resp_1" - assert len(client.beta.responses.requests) == 1 + assert len(client.requests) == 1 diff --git a/tests/unit/test_call_model.py b/tests/unit/test_call_model.py index aedb529..83be11a 100644 --- a/tests/unit/test_call_model.py +++ b/tests/unit/test_call_model.py @@ -1,61 +1,35 @@ from __future__ import annotations -from openrouter_agent import call_model, step_count_is, tool - - -class Responses: - def __init__(self) -> None: - self.requests = [] +from typing import Any, Dict, List - async def send_async(self, **kwargs): - self.requests.append(kwargs) - if len(self.requests) == 1: - return { - "id": "resp_1", - "output": [ - { - "type": "function_call", - "id": "item_1", - "callId": "call_1", - "name": "double", - "arguments": '{"value": 2}', - } - ], - } - return { - "id": "resp_2", - "output": [{"type": "message", "content": [{"type": "output_text", "text": "4"}]}], - "usage": {"total_tokens": 3}, - } - - -class Beta: - def __init__(self) -> None: - self.responses = Responses() +from openrouter_agent import call_model, step_count_is, tool +from tests._fixtures import MemoryStateAccessor, QueuedClient, text_response, tool_call_response, usage_block -class Client: - def __init__(self) -> None: - self.beta = Beta() - self.chat = object() +def double_turns() -> List[Dict[str, Any]]: + """The two-turn exchange every tool test in this file drives: call, then "4".""" + return [ + tool_call_response("resp_1", "double", call_id="call_1", arguments='{"value": 2}'), + text_response("resp_2", "4", usage=usage_block(total_tokens=3)), + ] async def test_call_model_uses_responses_api_and_executes_tool_loop() -> None: - client = Client() + client = QueuedClient(double_turns()) double = tool(name="double", input_schema=dict, execute=lambda params, ctx: {"value": params["value"] * 2}) result = call_model(client, {"model": "test/model", "input": "double 2", "tools": [double]}) assert await result.get_text() == "4" - assert len(client.beta.responses.requests) == 2 - assert client.beta.responses.requests[0]["stream"] is True - assert client.beta.responses.requests[0]["tools"][0]["name"] == "double" - assert client.beta.responses.requests[1]["input"][-1]["type"] == "function_call_output" + assert len(client.requests) == 2 + assert client.requests[0]["stream"] is True + assert client.requests[0]["tools"][0]["name"] == "double" + assert client.requests[1]["input"][-1]["type"] == "function_call_output" assert [call.name for call in await result.get_tool_calls()] == ["double"] async def test_call_model_forwards_request_options_to_responses_api() -> None: - client = Client() + client = QueuedClient([text_response("resp_1", "hello")]) result = call_model( client, @@ -64,13 +38,13 @@ async def test_call_model_forwards_request_options_to_responses_api() -> None: ) await result.get_response() - assert client.beta.responses.requests[0]["http_headers"]["x-test"] == "yes" - assert client.beta.responses.requests[0]["http_headers"]["x-openrouter-callmodel"] == "true" - assert client.beta.responses.requests[0]["timeout_ms"] == 1234 + assert client.requests[0]["http_headers"]["x-test"] == "yes" + assert client.requests[0]["http_headers"]["x-openrouter-callmodel"] == "true" + assert client.requests[0]["timeout_ms"] == 1234 async def test_model_result_stream_consumers_are_reusable() -> None: - client = Client() + client = QueuedClient(double_turns()) noop = tool(name="double", input_schema=dict, execute=lambda params, ctx: {"value": 4}) result = call_model(client, {"model": "test/model", "input": "double 2", "tools": [noop]}) @@ -80,7 +54,7 @@ async def test_model_result_stream_consumers_are_reusable() -> None: async def test_allow_final_response_executes_pending_tool_before_no_tools_turn() -> None: - client = Client() + client = QueuedClient(double_turns()) double = tool(name="double", input_schema=dict, execute=lambda params, ctx: {"value": params["value"] * 2}) result = call_model( @@ -95,19 +69,19 @@ async def test_allow_final_response_executes_pending_tool_before_no_tools_turn() ) assert await result.get_text() == "4" - assert len(client.beta.responses.requests) == 2 + assert len(client.requests) == 2 # Tools stay in the request (prompt-cache prefix preserved); calling is # forbidden via tool_choice instead of stripping the tools block. - assert "tools" in client.beta.responses.requests[1] - assert client.beta.responses.requests[1]["tool_choice"] == "none" - second_input = client.beta.responses.requests[1]["input"] + assert "tools" in client.requests[1] + assert client.requests[1]["tool_choice"] == "none" + second_input = client.requests[1]["input"] types = [item.get("type") for item in second_input] assert types.index("function_call") < types.index("function_call_output") assert second_input[-1] == {"role": "user", "content": "summarize"} async def test_next_turn_params_are_applied_to_followup_request() -> None: - client = Client() + client = QueuedClient(double_turns()) double = tool( name="double", input_schema=dict, @@ -118,37 +92,17 @@ async def test_next_turn_params_are_applied_to_followup_request() -> None: result = call_model(client, {"model": "test/model", "input": "double 2", "tools": [double]}) await result.get_response() - assert client.beta.responses.requests[1]["temperature"] == 0.2 + assert client.requests[1]["temperature"] == 0.2 async def test_user_input_persists_with_response_in_state() -> None: - class State: - def __init__(self) -> None: - self.current = None - - async def load(self): - return self.current - - async def save(self, state): - self.current = state - - class TextResponses: - async def send_async(self, **kwargs): - return { - "id": "resp_text", - "output": [{"type": "message", "content": [{"type": "output_text", "text": "hi"}]}], - } - - class TextClient: - def __init__(self) -> None: - self.beta = type("Beta", (), {"responses": TextResponses()})() - - state = State() - client = TextClient() + accessor = MemoryStateAccessor() + client = QueuedClient([text_response("resp_text", "hi")]) - result = call_model(client, {"model": "test/model", "input": "hello", "state": state}) + result = call_model(client, {"model": "test/model", "input": "hello", "state": accessor}) await result.get_response() - assert state.current.status == "complete" - assert state.current.messages[0] == {"role": "user", "content": "hello"} - assert state.current.messages[-1]["type"] == "message" + assert accessor.stored is not None + assert accessor.stored.status == "complete" + assert accessor.stored.messages[0] == {"role": "user", "content": "hello"} + assert accessor.stored.messages[-1]["type"] == "message" diff --git a/tests/unit/test_conversation_state_serialization.py b/tests/unit/test_conversation_state_serialization.py index 798d100..74fe1b6 100644 --- a/tests/unit/test_conversation_state_serialization.py +++ b/tests/unit/test_conversation_state_serialization.py @@ -56,6 +56,7 @@ def test_round_trips_a_rich_awaiting_client_tools_state() -> None: assert restored.pending_tool_calls == [ ParsedToolCall(id="call_manual_1", name="exec_command", arguments={"command": "ls"}) ] + assert restored.unsent_tool_results is not None assert restored.unsent_tool_results[0].call_id == "call_auto_1" diff --git a/tests/unit/test_manual_tool_pending_state.py b/tests/unit/test_manual_tool_pending_state.py index d5dd25b..13104b3 100644 --- a/tests/unit/test_manual_tool_pending_state.py +++ b/tests/unit/test_manual_tool_pending_state.py @@ -1,63 +1,9 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List from openrouter_agent import call_model, tool -from openrouter_agent.tool_types import ConversationState - - -class QueuedResponses: - def __init__(self, responses: List[Dict[str, Any]]) -> None: - self._responses = list(responses) - self.requests: List[Dict[str, Any]] = [] - - async def send_async(self, **kwargs: Any) -> Any: - self.requests.append(kwargs) - return self._responses.pop(0) - - -class QueuedClient: - def __init__(self, responses: List[Dict[str, Any]]) -> None: - self.beta = type("Beta", (), {"responses": QueuedResponses(responses)})() - - -class MemoryStateAccessor: - def __init__(self) -> None: - self.stored: Optional[ConversationState] = None - - async def load(self) -> Optional[ConversationState]: - return self.stored - - async def save(self, state: ConversationState) -> None: - self.stored = state - - -def function_call_item(call_id: str, name: str, arguments: str) -> Dict[str, Any]: - return { - "type": "function_call", - "id": f"fc_{call_id}", - "callId": call_id, - "name": name, - "arguments": arguments, - } - - -def text_response(response_id: str, text: str) -> Dict[str, Any]: - return { - "id": response_id, - "output": [ - { - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": text}], - } - ], - } - - -def make_response(response_id: str, output: List[Dict[str, Any]]) -> Dict[str, Any]: - return {"id": response_id, "output": output} - +from tests._fixtures import MemoryStateAccessor, QueuedClient, function_call_item, make_response, text_response auto_tool = tool( name="auto_search", @@ -91,7 +37,8 @@ async def test_all_manual_round_stops_loop_with_awaiting_client_tools() -> None: assert len(state.pending_tool_calls) == 1 # No follow-up request -- the loop stopped after the unresolved manual call. - assert len(client.beta.responses.requests) == 1 + assert len(client.requests) == 1 + assert accessor.stored is not None assert accessor.stored.status == "awaiting_client_tools" @@ -125,7 +72,7 @@ async def test_mixed_auto_and_manual_round_persists_auto_output_and_pauses_manua # No follow-up request: it would contain exec_command's function_call with # no matching function_call_output, which providers reject. - assert len(client.beta.responses.requests) == 1 + assert len(client.requests) == 1 state = await result.get_state() assert state.status == "awaiting_client_tools" @@ -152,7 +99,7 @@ async def test_no_state_accessor_nothing_persisted_but_response_readable() -> No response = await result.get_response() assert response["id"] == "resp_manual" - assert len(client.beta.responses.requests) == 1 + assert len(client.requests) == 1 pending = await result.get_pending_tool_calls() assert pending == [] @@ -181,6 +128,7 @@ async def test_clears_pending_manual_calls_only_after_a_resume_succeeds() -> Non }, ).get_response() + assert accessor.stored is not None assert accessor.stored.status == "complete" assert not accessor.stored.pending_tool_calls @@ -223,7 +171,9 @@ async def send_async(self, **kwargs: Any) -> Any: raised = True assert raised + assert accessor.stored is not None assert accessor.stored.status == "awaiting_client_tools" + assert accessor.stored.pending_tool_calls is not None assert accessor.stored.pending_tool_calls[0].id == "call_manual_1" diff --git a/tests/unit/test_mcp_tool_branding.py b/tests/unit/test_mcp_tool_branding.py index a35e8cc..1e6110a 100644 --- a/tests/unit/test_mcp_tool_branding.py +++ b/tests/unit/test_mcp_tool_branding.py @@ -1,38 +1,7 @@ from __future__ import annotations -from typing import Any, Dict, List - from openrouter_agent import call_model, is_mcp_tool, mark_mcp, tool - - -class QueuedResponses: - def __init__(self, responses: List[Dict[str, Any]]) -> None: - self._responses = list(responses) - self.requests: List[Dict[str, Any]] = [] - - async def send_async(self, **kwargs: Any) -> Any: - self.requests.append(kwargs) - return self._responses.pop(0) - - -class QueuedClient: - def __init__(self, responses: List[Dict[str, Any]]) -> None: - self.beta = type("Beta", (), {"responses": QueuedResponses(responses)})() - - -def function_call_item(call_id: str, name: str, arguments: str) -> Dict[str, Any]: - return {"type": "function_call", "id": f"fc_{call_id}", "callId": call_id, "name": name, "arguments": arguments} - - -def text_response(response_id: str, text: str) -> Dict[str, Any]: - return { - "id": response_id, - "output": [{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": text}]}], - } - - -def make_response(response_id: str, output: List[Dict[str, Any]]) -> Dict[str, Any]: - return {"id": response_id, "output": output} +from tests._fixtures import QueuedClient, function_call_item, make_response, text_response def test_mark_mcp_is_non_mutating_and_is_mcp_tool_detects_the_brand() -> None: diff --git a/tests/unit/test_mixed_manual_tool_round.py b/tests/unit/test_mixed_manual_tool_round.py new file mode 100644 index 0000000..63b5526 --- /dev/null +++ b/tests/unit/test_mixed_manual_tool_round.py @@ -0,0 +1,99 @@ +"""A round mixing auto and manual tool calls must not send an orphaned function_call. + +Ports `packages/agent/tests/unit/mixed-manual-tool-round.test.ts`. + +The guard: when one round returns both an auto-executable call and a manual +(no-`execute`) call, the loop must stop and surface the response so the caller can +resolve the manual call. Sending a follow-up would put `exec_command`'s +`function_call` in the input with no matching `function_call_output`, which +providers reject outright: + + 400 "No tool output found for function call call_manual_1" + +The failure is a hard provider error on real traffic, and nothing in the +deterministic suite would catch it — which is exactly why the request *count*, not +just the final text, is the assertion that matters here. + +Relationship to `test_manual_tool_pending_state.py`: that file covers the stop +behavior plus state persistence for pending manual calls. It does **not** cover +the all-resolve follow-up pairing below, and it does not assert request counts for +the mixed round. Both files are kept so the upstream↔port test mapping stays 1:1 +(`mixed-manual-tool-round.test.ts` → this file); do not delete either as redundant. +""" + +from __future__ import annotations + +from typing import Any, Dict, List + +from openrouter_agent import call_model, tool +from tests._fixtures import QueuedClient, function_call_item, make_response, text_response + +# `execute=False` marks a manual tool. `execute=None` raises instead +# (`tool.py:47-48`), so the literal False is required. +manual_tool = tool(name="exec_command", input_schema=dict, output_schema=dict, execute=False) +auto_tool = tool( + name="auto_search", + input_schema=dict, + output_schema=dict, + execute=lambda params, ctx: {"result": "found it"}, +) + + +async def test_stops_instead_of_sending_a_follow_up_with_an_orphaned_function_call() -> None: + mixed_round = make_response( + "resp_mixed", + [ + function_call_item("call_auto_1", "auto_search", '{"query":"docs"}'), + function_call_item("call_manual_1", "exec_command", '{"command":"ls"}'), + ], + ) + client = QueuedClient([mixed_round]) + + result = call_model( + client, + {"model": "test-model", "input": "do both things", "tools": [auto_tool, manual_tool]}, + ) + response = await result.get_response() + + # The response carrying the unresolved manual call is surfaced, so the caller + # can execute it and continue. + assert response["id"] == "resp_mixed" + # And crucially: no follow-up was sent. Only the queued response was consumed. + assert len(client.requests) == 1, ( + "a follow-up request was sent for a round with an unresolved manual call; " + "its input would carry an orphaned function_call and the provider would 400" + ) + + +async def test_still_loops_when_every_tool_call_in_the_round_resolves() -> None: + """The stop above must not over-trigger: an all-auto round still continues.""" + client = QueuedClient( + [ + make_response("resp_auto", [function_call_item("call_auto_1", "auto_search", '{"query":"docs"}')]), + text_response("resp_final", "All done."), + ] + ) + + result = call_model( + client, + {"model": "test-model", "input": "search the docs", "tools": [auto_tool, manual_tool]}, + ) + text = await result.get_text() + + assert text == "All done." + assert len(client.requests) == 2 + + follow_up_input: List[Any] = client.requests[1]["input"] + assert isinstance(follow_up_input, list) + outputs: List[Dict[str, Any]] = [ + item for item in follow_up_input if isinstance(item, dict) and item.get("type") == "function_call_output" + ] + assert len(outputs) == 1, f"expected exactly one function_call_output, got {len(outputs)}" + + # Upstream asserts `callId` here. This port emits snake_case `call_id`: + # `ModelResult._send` normalizes camelCase to the generated SDK's snake_case at + # the transport boundary (`model_result.py:148-156`), and this assertion reads + # the outgoing request. This is a deliberate divergence — do not "fix" it back + # to callId, it will fail. + assert outputs[0]["call_id"] == "call_auto_1" + assert "found it" in outputs[0]["output"] diff --git a/tests/unit/test_model_result_hooks.py b/tests/unit/test_model_result_hooks.py index 9e50f2f..a640bb8 100644 --- a/tests/unit/test_model_result_hooks.py +++ b/tests/unit/test_model_result_hooks.py @@ -3,50 +3,14 @@ from typing import Any, Dict, List from openrouter_agent import HookEntry, HookName, HooksManager, call_model, step_count_is, tool - - -class QueuedResponses: - def __init__(self, responses: List[Dict[str, Any]]) -> None: - self._responses = list(responses) - self.requests: List[Dict[str, Any]] = [] - - async def send_async(self, **kwargs: Any) -> Any: - self.requests.append(kwargs) - return self._responses.pop(0) - - -class QueuedClient: - def __init__(self, responses: List[Dict[str, Any]]) -> None: - self.beta = type("Beta", (), {"responses": QueuedResponses(responses)})() - - -def function_call_item(call_id: str, name: str, arguments: str) -> Dict[str, Any]: - return {"type": "function_call", "id": f"fc_{call_id}", "callId": call_id, "name": name, "arguments": arguments} - - -def text_response(response_id: str, text: str, usage: Any = None) -> Dict[str, Any]: - resp = { - "id": response_id, - "model": "test-model-v1", - "output": [{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": text}]}], - } - if usage is not None: - resp["usage"] = usage - return resp - - -def tool_call_response(response_id: str, usage: Any = None) -> Dict[str, Any]: - resp = {"id": response_id, "output": [function_call_item(f"call_{response_id}", "echo", "{}")]} - if usage is not None: - resp["usage"] = usage - return resp - - -def usage_block(**overrides: Any) -> Dict[str, Any]: - base = {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150, "cost": 0.002} - base.update(overrides) - return base - +from tests._fixtures import ( + QueuedClient, + function_call_item, + make_response, + text_response, + tool_call_response, + usage_block, +) echo_tool = tool(name="echo", input_schema=dict, output_schema=dict, execute=lambda params, ctx: {"ok": True}) @@ -97,7 +61,7 @@ async def test_session_end_reason_max_turns_when_stop_condition_halts() -> None: async def test_post_model_call_emits_once_per_turn_with_turn_type_labels() -> None: - client = QueuedClient([tool_call_response("r1", usage_block()), text_response("r2", "done", usage_block())]) + client = QueuedClient([tool_call_response("r1", usage=usage_block()), text_response("r2", "done", usage_block())]) hooks = HooksManager() calls: List[Dict[str, Any]] = [] hooks.on(HookName.PostModelCall.value, HookEntry(handler=lambda p, c: calls.append(p))) @@ -112,7 +76,7 @@ async def test_post_model_call_emits_once_per_turn_with_turn_type_labels() -> No async def test_session_end_aggregates_usage_totals_across_calls() -> None: client = QueuedClient( [ - tool_call_response("r1", usage_block(input_tokens=10, output_tokens=5, total_tokens=15, cost=0.01)), + tool_call_response("r1", usage=usage_block(input_tokens=10, output_tokens=5, total_tokens=15, cost=0.01)), text_response("r2", "done", usage_block(input_tokens=20, output_tokens=8, total_tokens=28, cost=0.02)), ] ) @@ -148,12 +112,12 @@ async def test_pre_tool_use_block_prevents_execution_and_synthesizes_rejection() client = QueuedClient([tool_call_response("r1"), text_response("r2", "done")]) hooks = HooksManager() executed: List[str] = [] - blocking_tool = tool( - name="echo", - input_schema=dict, - output_schema=dict, - execute=lambda params, ctx: executed.append("ran") or {"ok": True}, - ) + + def record_and_ok(params: Any, ctx: Any) -> Dict[str, Any]: + executed.append("ran") + return {"ok": True} + + blocking_tool = tool(name="echo", input_schema=dict, output_schema=dict, execute=record_and_ok) hooks.on(HookName.PreToolUse.value, HookEntry(handler=lambda p, c: {"block": "not allowed"})) await call_model( @@ -161,7 +125,7 @@ async def test_pre_tool_use_block_prevents_execution_and_synthesizes_rejection() ).get_text() assert executed == [] - followup_input = client.beta.responses.requests[1]["input"] + followup_input = client.requests[1]["input"] output_item = next(i for i in followup_input if i.get("type") == "function_call_output") assert "not allowed" in output_item["output"] @@ -219,7 +183,7 @@ def stop_handler(payload: Any, ctx: Any) -> Any: assert stop_calls["n"] == 2 # ...but exactly one model request was ever sent -- the resume itself # cost nothing. - assert len(client.beta.responses.requests) == 1 + assert len(client.requests) == 1 assert response["id"] == "r1" @@ -250,7 +214,7 @@ def stop_handler(payload: Any, ctx: Any) -> Any: # Exactly two real model requests: the initial call, and the follow-up # after the (only) tool round actually executed -- not one per Stop-hook # invocation. - assert len(client.beta.responses.requests) == 2 + assert len(client.requests) == 2 async def test_permission_request_deny_synthesizes_rejection_without_pausing() -> None: @@ -258,11 +222,16 @@ async def test_permission_request_deny_synthesizes_rejection_without_pausing() - hooks = HooksManager() hooks.on(HookName.PermissionRequest.value, HookEntry(handler=lambda p, c: {"decision": "deny", "reason": "nope"})) executed: List[str] = [] + + def record_and_ok(params: Any, ctx: Any) -> Dict[str, Any]: + executed.append("ran") + return {"ok": True} + gated_tool = tool( name="echo", input_schema=dict, output_schema=dict, - execute=lambda params, ctx: executed.append("ran") or {"ok": True}, + execute=record_and_ok, require_approval=True, ) @@ -272,7 +241,7 @@ async def test_permission_request_deny_synthesizes_rejection_without_pausing() - assert text == "done" assert executed == [] - followup_input = client.beta.responses.requests[1]["input"] + followup_input = client.requests[1]["input"] output_item = next(i for i in followup_input if i.get("type") == "function_call_output") assert "nope" in output_item["output"] @@ -311,7 +280,7 @@ async def test_user_prompt_submit_can_reject_a_string_prompt() -> None: assert raised # The model was never called: the prompt was rejected before dispatch. - assert len(client.beta.responses.requests) == 0 + assert len(client.requests) == 0 async def test_user_prompt_submit_can_mutate_a_string_prompt() -> None: @@ -324,7 +293,7 @@ async def test_user_prompt_submit_can_mutate_a_string_prompt() -> None: await call_model(client, {"model": "test-model", "input": "the secret is 42", "hooks": hooks}).get_text() - sent_input = client.beta.responses.requests[0]["input"] + sent_input = client.requests[0]["input"] assert "[redacted]" in sent_input[0]["content"] @@ -341,7 +310,7 @@ async def test_user_prompt_submit_mutates_last_user_message_in_array_input() -> {"model": "test-model", "input": [{"role": "user", "content": "hello there"}], "hooks": hooks}, ).get_text() - sent_input = client.beta.responses.requests[0]["input"] + sent_input = client.requests[0]["input"] assert sent_input[-1]["content"] == "HELLO THERE" @@ -353,12 +322,12 @@ async def test_pre_tool_use_mutated_input_actually_reaches_tool_execute() -> Non HookName.PreToolUse.value, HookEntry(handler=lambda p, c: {"mutated_input": {"mutated": True, **p["tool_input"]}}), ) - recording_tool = tool( - name="echo", - input_schema=dict, - output_schema=dict, - execute=lambda params, ctx: received_args.append(params) or {"ok": True}, - ) + + def record_args(params: Any, ctx: Any) -> Dict[str, Any]: + received_args.append(params) + return {"ok": True} + + recording_tool = tool(name="echo", input_schema=dict, output_schema=dict, execute=record_args) await call_model( client, {"model": "test-model", "input": "hi", "tools": [recording_tool], "hooks": hooks} @@ -372,6 +341,7 @@ async def test_session_end_fires_with_reason_error_on_no_tools_transport_failure no-tools path's transport raises, and the drain must not mask the original error.""" + # Bespoke: injects a transport error, which QueuedClient cannot express. class FailingResponses: async def send_async(self, **kwargs: Any) -> Any: raise RuntimeError("transport exploded") @@ -399,13 +369,13 @@ async def test_permission_request_allow_executes_promoted_tool_exactly_once() -> the promotion branch and again in the normal auto-execute round.""" client = QueuedClient( [ - { - "id": "r1", - "output": [ - function_call_item("call_auto", "auto_run", "{}"), - function_call_item("call_gated", "gated_run", "{}"), + make_response( + "r1", + [ + function_call_item("call_auto", "auto_run"), + function_call_item("call_gated", "gated_run"), ], - }, + ), text_response("r2", "done"), ] ) diff --git a/tests/unit/test_parity_requirements.py b/tests/unit/test_parity_requirements.py index 26ec397..2d3cf21 100644 --- a/tests/unit/test_parity_requirements.py +++ b/tests/unit/test_parity_requirements.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from typing import Any, Dict, List import httpx from pydantic import BaseModel @@ -11,42 +12,42 @@ from openrouter_agent.stop_conditions import is_stop_condition_met from openrouter_agent.tool_executor import apply_on_response_received_hooks, execute_tool from openrouter_agent.tool_types import ParsedToolCall +from tests._fixtures import ( + MemoryStateAccessor, + QueuedClient, + function_call_item, + make_response, + text_response, + tool_call_response, + usage_block, +) + + +def double_then_done() -> List[Dict[str, Any]]: + """One `double` tool call, then a terminal "done".""" + return [ + tool_call_response( + "resp_tool", + "double", + call_id="call_1", + arguments='{"value": 2}', + usage=usage_block(total_tokens=5, input_tokens=2, output_tokens=3), + ), + text_response("resp_done", "done"), + ] -class Responses: - def __init__(self) -> None: - self.requests = [] - - async def send_async(self, **kwargs): - self.requests.append(kwargs) - if len(self.requests) == 1: - return { - "id": "resp_tool", - "output": [ - { - "type": "function_call", - "id": "item_1", - "callId": "call_1", - "name": "double", - "arguments": '{"value": 2}', - } - ], - "usage": {"total_tokens": 5, "input_tokens": 2, "output_tokens": 3}, - } - return { - "id": "resp_done", - "output": [{"type": "message", "content": [{"type": "output_text", "text": "done"}]}], - } - - -class Beta: - def __init__(self) -> None: - self.responses = Responses() - +def pause_then_done(tool_name: str, pause_first: bool = True) -> List[Dict[str, Any]]: + """A pausing tool call (approval/HITL) followed by the terminal turn. -class Client: - def __init__(self) -> None: - self.beta = Beta() + `pause_first=False` builds the resume client, whose only queued turn is the + completion -- so an unexpected extra request fails loudly. + """ + turns: List[Dict[str, Any]] = [] + if pause_first: + turns.append(tool_call_response(f"resp_{tool_name}", tool_name, call_id="call_1")) + turns.append(text_response("resp_done", "done")) + return turns async def test_max_tokens_used_matches_upstream_total_tokens_only() -> None: @@ -57,26 +58,37 @@ async def test_max_tokens_used_matches_upstream_total_tokens_only() -> None: async def test_full_and_tool_streams_include_turn_and_tool_events() -> None: - client = Client() + client = QueuedClient(double_then_done()) double = tool(name="double", input_schema=dict, execute=lambda params, ctx: {"value": params["value"] * 2}) result = ModelResult( {"client": client, "request": {"model": "test", "input": "go", "tools": [double]}, "tools": [double]} ) full_events = [event async for event in result.get_full_responses_stream()] - assert "turn.start" in [event["type"] for event in full_events] - assert "turn.end" in [event["type"] for event in full_events] - assert "tool.result" in [event["type"] for event in full_events] - assert "tool.call_output" in [event["type"] for event in full_events] + full_types = [event["type"] for event in full_events] + # Order and count, not membership: a membership check passes even when + # turn.end fires twice, never fires, or precedes turn.start. See + # tests/unit/test_turn_end_race_condition.py for why that matters here. + assert full_types.count("turn.start") == full_types.count("turn.end") + assert full_types.index("turn.start") < full_types.index("turn.end") + for expected in ("tool.result", "tool.call_output"): + assert full_types.count(expected) == 1, f"{expected}: {full_types}" + # The tool round's events land inside the turn that produced them. + assert full_types.index("turn.start") < full_types.index("tool.result") result2 = ModelResult( - {"client": Client(), "request": {"model": "test", "input": "go", "tools": [double]}, "tools": [double]} + { + "client": QueuedClient(double_then_done()), + "request": {"model": "test", "input": "go", "tools": [double]}, + "tools": [double], + } ) tool_events = [event async for event in result2.get_tool_stream()] - assert "turn.start" in [event["type"] for event in tool_events] - assert "turn.end" in [event["type"] for event in tool_events] - assert "tool_result" in [event["type"] for event in tool_events] - assert "tool_call_output" in [event["type"] for event in tool_events] + tool_types = [event["type"] for event in tool_events] + assert tool_types.count("turn.start") == tool_types.count("turn.end") + assert tool_types.index("turn.start") < tool_types.index("turn.end") + for expected in ("tool_result", "tool_call_output"): + assert tool_types.count(expected) == 1, f"{expected}: {tool_types}" async def test_generator_tool_emits_preliminary_event_before_completion() -> None: @@ -197,49 +209,31 @@ def on_response_received(raw, ctx): }, ) - class StateAccessor: - async def load(self): - return state + accessor = MemoryStateAccessor() + accessor.stored = state - async def save(self, new_state): - self.saved = new_state - - client = Client() - result = call_model(client, {"model": "test/model", "input": "next", "tools": [hitl], "state": StateAccessor()}) + client = QueuedClient(double_then_done()) + result = call_model(client, {"model": "test/model", "input": "next", "tools": [hitl], "state": accessor}) await result.get_response() assert calls == [] async def test_new_messages_stream_filters_unknown_manual_tool_calls() -> None: - class OneShotResponses: - async def send_async(self, **kwargs): - return { - "id": "resp_1", - "output": [ - { - "type": "function_call", - "id": "ghost_item", - "callId": "ghost_call", - "name": "ghost", - "arguments": "{}", - }, - { - "type": "function_call", - "id": "real_item", - "callId": "real_call", - "name": "real", - "arguments": "{}", - }, + client = QueuedClient( + [ + make_response( + "resp_1", + [ + function_call_item("ghost_call", "ghost"), + function_call_item("real_call", "real"), ], - } - - class OneShotClient: - def __init__(self) -> None: - self.beta = type("Beta", (), {"responses": OneShotResponses()})() + ) + ] + ) real = tool(name="real", input_schema=dict, execute=False) - result = call_model(OneShotClient(), {"model": "test/model", "input": "call tools", "tools": [real]}) + result = call_model(client, {"model": "test/model", "input": "call tools", "tools": [real]}) messages = [item async for item in result.get_new_messages_stream()] assert [item["name"] for item in messages] == ["real"] @@ -262,53 +256,8 @@ class Output(BaseModel): assert "originalOutput" in rewritten[1]["output"] -class MemoryState: - def __init__(self): - self.current = None - self.saved = [] - - async def load(self): - return self.current - - async def save(self, new_state): - self.current = new_state - self.saved.append(new_state) - - -class PauseThenDoneResponses: - def __init__(self, tool_name: str, pause_first: bool = True) -> None: - self.tool_name = tool_name - self.pause_first = pause_first - self.requests = [] - - async def send_async(self, **kwargs): - self.requests.append(kwargs) - if self.pause_first and len(self.requests) == 1: - return { - "id": f"resp_{self.tool_name}", - "output": [ - { - "type": "function_call", - "id": "item_1", - "callId": "call_1", - "name": self.tool_name, - "arguments": "{}", - } - ], - } - return { - "id": "resp_done", - "output": [{"type": "message", "content": [{"type": "output_text", "text": "done"}]}], - } - - -class PauseClient: - def __init__(self, tool_name: str, pause_first: bool = True) -> None: - self.beta = type("Beta", (), {"responses": PauseThenDoneResponses(tool_name, pause_first)})() - - async def test_approval_pause_persists_tool_call_turn_and_resume_orders_output_after_call() -> None: - state = MemoryState() + state = MemoryStateAccessor() delete = tool( name="delete", input_schema=dict, @@ -317,30 +266,31 @@ async def test_approval_pause_persists_tool_call_turn_and_resume_orders_output_a require_approval=True, ) - first_client = PauseClient("delete") + first_client = QueuedClient(pause_then_done("delete")) first = call_model(first_client, {"model": "test", "input": "delete it", "tools": [delete], "state": state}) await first.get_response() - paused = state.current + paused = state.stored + assert paused is not None assert paused.status == "awaiting_approval" assert paused.previous_response_id == "resp_delete" assert [item.get("type") for item in paused.messages][-1:] == ["function_call"] - resume_client = PauseClient("delete", pause_first=False) + resume_client = QueuedClient(pause_then_done("delete", pause_first=False)) resumed = call_model( resume_client, {"model": "test", "input": [], "tools": [delete], "state": state, "approve_tool_calls": ["call_1"]}, ) await resumed.get_response() - sent_input = resume_client.beta.responses.requests[0]["input"] + sent_input = resume_client.requests[0]["input"] types = [item.get("type") for item in sent_input] assert types.index("function_call") < types.index("function_call_output") assert state.saved[-1].previous_response_id == "resp_done" async def test_hitl_pause_persists_tool_call_turn_and_resume_orders_output_after_call() -> None: - state = MemoryState() + state = MemoryStateAccessor() calls = 0 def decide(params, ctx): @@ -350,28 +300,32 @@ def decide(params, ctx): approve = tool(name="approve", input_schema=dict, output_schema=dict, on_tool_called=decide) - first_client = PauseClient("approve") + first_client = QueuedClient(pause_then_done("approve")) first = call_model(first_client, {"model": "test", "input": "approve it", "tools": [approve], "state": state}) await first.get_response() - paused = state.current + paused = state.stored + assert paused is not None assert paused.status == "awaiting_hitl" assert paused.previous_response_id == "resp_approve" assert [item.get("type") for item in paused.messages][-1:] == ["function_call"] - resume_client = PauseClient("approve", pause_first=False) + resume_client = QueuedClient(pause_then_done("approve", pause_first=False)) resumed = call_model( resume_client, {"model": "test", "input": [], "tools": [approve], "state": state, "approve_tool_calls": ["call_1"]}, ) await resumed.get_response() - sent_input = resume_client.beta.responses.requests[0]["input"] + sent_input = resume_client.requests[0]["input"] types = [item.get("type") for item in sent_input] assert types.index("function_call") < types.index("function_call_output") async def test_model_result_tool_calls_stream_reconstructs_streamed_argument_deltas() -> None: + # Bespoke: this must yield an SSE *event* sequence, not a single result, so + # it cannot be a QueuedClient. Its terminal payload still uses the shared + # builders so it carries every required response field. class StreamResponses: async def send_async(self, **kwargs): async def events(): @@ -384,18 +338,7 @@ async def events(): yield {"type": "response.function_call_arguments.done", "itemId": "item_1"} yield { "type": "response.completed", - "response": { - "id": "resp_stream", - "output": [ - { - "type": "function_call", - "id": "item_1", - "callId": "call_1", - "name": "lookup", - "arguments": '{"q":"x"}', - } - ], - }, + "response": tool_call_response("resp_stream", "lookup", call_id="call_1", arguments='{"q":"x"}'), } return events() diff --git a/tests/unit/test_resume_string_input.py b/tests/unit/test_resume_string_input.py index 1515023..e53c390 100644 --- a/tests/unit/test_resume_string_input.py +++ b/tests/unit/test_resume_string_input.py @@ -1,42 +1,7 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional - from openrouter_agent import call_model -from openrouter_agent.tool_types import ConversationState - - -class QueuedResponses: - def __init__(self, responses: List[Dict[str, Any]]) -> None: - self._responses = list(responses) - self.requests: List[Dict[str, Any]] = [] - - async def send_async(self, **kwargs: Any) -> Any: - self.requests.append(kwargs) - return self._responses.pop(0) - - -class QueuedClient: - def __init__(self, responses: List[Dict[str, Any]]) -> None: - self.beta = type("Beta", (), {"responses": QueuedResponses(responses)})() - - -class MemoryStateAccessor: - def __init__(self) -> None: - self.stored: Optional[ConversationState] = None - - async def load(self) -> Optional[ConversationState]: - return self.stored - - async def save(self, state: ConversationState) -> None: - self.stored = state - - -def text_response(response_id: str, text: str) -> Dict[str, Any]: - return { - "id": response_id, - "output": [{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": text}]}], - } +from tests._fixtures import MemoryStateAccessor, QueuedClient, text_response async def test_normalizes_a_bare_string_input_when_resuming_loaded_history() -> None: @@ -45,11 +10,18 @@ async def test_normalizes_a_bare_string_input_when_resuming_loaded_history() -> await call_model(client, {"model": "test-model", "input": "First question", "state": accessor}).get_text() assert accessor.stored is not None - assert len(accessor.stored.messages) > 0 + # Assert *which* messages persisted, not merely that some did: a bare + # `len(...) > 0` passes whether the port stored the user turn, the assistant + # turn, both, or forty. + roles = [message["role"] for message in accessor.stored.messages if isinstance(message, dict) and "role" in message] + assert "user" in roles, f"user turn was not persisted; roles={roles}" + stored_text = str(accessor.stored.messages) + assert "First question" in stored_text + assert "First answer." in stored_text await call_model(client, {"model": "test-model", "input": "Follow-up question", "state": accessor}).get_text() - request = client.beta.responses.requests[1] + request = client.requests[1] assert isinstance(request["input"], list) for item in request["input"]: assert not isinstance(item, str) @@ -68,7 +40,7 @@ async def test_still_accepts_array_input_when_resuming_loaded_history() -> None: {"model": "test-model", "input": [{"role": "user", "content": "Follow-up question"}], "state": accessor}, ).get_text() - request = client.beta.responses.requests[1] + request = client.requests[1] last = request["input"][-1] assert last["role"] == "user" assert last["content"] == "Follow-up question" diff --git a/tests/unit/test_tool_execution_once.py b/tests/unit/test_tool_execution_once.py new file mode 100644 index 0000000..f9d4451 --- /dev/null +++ b/tests/unit/test_tool_execution_once.py @@ -0,0 +1,118 @@ +"""A tool executes exactly once per round — never twice, never zero times. + +Ports `packages/agent/tests/unit/tool-execution-once.test.ts`. + +Upstream regression this guards: a revision where `handleApprovalCheck` +pre-executed auto-approve tools on every response and the main loop then ran them +again, producing double (and in one path triple) execution. For a tool with side +effects — a payment, a write, an email — that is the difference between correct +and catastrophic, and it is invisible to any assertion that only checks the final +text. + +These four cases already hold on this port, so this file *pins* behavior rather +than exposing a bug. That is deliberate: nothing else in the deterministic suite +asserts an execution count, so a re-introduced double-execution would otherwise +reach `main` with CI green. The only other exactly-once assertion lives in +`tests/e2e/`, which is skipped without an API key. + +Divergence from upstream's test structure: upstream casts `ModelResult` to an +internal type and calls `executeToolsIfNeeded()` directly. This port has no such +method — the tool loop is inlined in `ModelResult._run` (`model_result.py:825-882`) +— so these drive the public `call_model` path instead. Same invariant, no reliance +on private shape that the next sync could rename. +""" + +from __future__ import annotations + +from typing import Any, Dict, List + +from openrouter_agent import HookEntry, HookName, HooksManager, call_model, tool +from tests._fixtures import QueuedClient, text_response, tool_call_response + + +class ExecutionCounter: + """Counts executions. + + A class rather than `lambda p, c: calls.append(...) or {...}`: `list.append` + returns None, so the lambda form is both a mypy `func-returns-value` error and + a silent way to return the wrong tool output. + """ + + def __init__(self) -> None: + self.count = 0 + + def __call__(self, params: Any, ctx: Any) -> Dict[str, Any]: + self.count += 1 + return {"ok": True} + + +def _client() -> QueuedClient: + """One tool round, then a terminal text turn.""" + return QueuedClient([tool_call_response("r1", "counted"), text_response("r2", "done")]) + + +async def test_auto_tool_executes_exactly_once_without_hooks() -> None: + execute = ExecutionCounter() + counted = tool(name="counted", input_schema=dict, output_schema=dict, execute=execute) + + await call_model(_client(), {"model": "test-model", "input": "go", "tools": [counted]}).get_text() + + assert execute.count == 1 + + +async def test_auto_tool_executes_exactly_once_with_pre_tool_use_hook() -> None: + """Attaching hooks must not add an execution — the original regression's shape.""" + execute = ExecutionCounter() + pre_tool_use_calls: List[Any] = [] + hooks = HooksManager() + hooks.on( + HookName.PreToolUse.value, + HookEntry(handler=lambda payload, ctx: pre_tool_use_calls.append(payload)), + ) + counted = tool(name="counted", input_schema=dict, output_schema=dict, execute=execute) + + await call_model( + _client(), + {"model": "test-model", "input": "go", "tools": [counted], "hooks": hooks}, + ).get_text() + + assert execute.count == 1 + assert len(pre_tool_use_calls) == 1 + + +async def test_gated_tool_executes_exactly_once_when_permission_request_allows() -> None: + execute = ExecutionCounter() + hooks = HooksManager() + hooks.on( + HookName.PermissionRequest.value, + HookEntry(handler=lambda payload, ctx: {"decision": "allow"}), + ) + # `require_approval=True` is consulted only when hooks are present + # (model_result.py:764); without hooks and without a state accessor the run + # raises instead of gating, so the HooksManager is load-bearing here. + gated = tool(name="counted", input_schema=dict, output_schema=dict, execute=execute, require_approval=True) + + await call_model( + _client(), + {"model": "test-model", "input": "go", "tools": [gated], "hooks": hooks}, + ).get_text() + + assert execute.count == 1 + + +async def test_gated_tool_never_executes_when_permission_request_denies() -> None: + """A denied tool must run zero times — the assertion that makes 'gating' mean anything.""" + execute = ExecutionCounter() + hooks = HooksManager() + hooks.on( + HookName.PermissionRequest.value, + HookEntry(handler=lambda payload, ctx: {"decision": "deny", "reason": "policy"}), + ) + gated = tool(name="counted", input_schema=dict, output_schema=dict, execute=execute, require_approval=True) + + await call_model( + _client(), + {"model": "test-model", "input": "go", "tools": [gated], "hooks": hooks}, + ).get_text() + + assert execute.count == 0 diff --git a/tests/unit/test_tool_factory_and_executor.py b/tests/unit/test_tool_factory_and_executor.py index 065b057..2d53b96 100644 --- a/tests/unit/test_tool_factory_and_executor.py +++ b/tests/unit/test_tool_factory_and_executor.py @@ -35,7 +35,9 @@ async def test_regular_tool_executes_and_validates() -> None: result = await execute_tool(created, ParsedToolCall(id="call_1", name="double", arguments={"value": 3})) assert result is not None - assert result["error"] is None if "error" in result else True + # A successful execution must not report an error: the key is either absent + # or explicitly None. `assert x is None if k in x else True` could not fail. + assert result.get("error") is None assert result["result"].doubled == 6 diff --git a/tests/unit/test_tool_terminal_empty_final.py b/tests/unit/test_tool_terminal_empty_final.py index 04118da..f5e75cb 100644 --- a/tests/unit/test_tool_terminal_empty_final.py +++ b/tests/unit/test_tool_terminal_empty_final.py @@ -1,42 +1,14 @@ from __future__ import annotations -from typing import Any, Dict, List +from typing import Any, Dict from openrouter_agent import call_model, tool - - -class QueuedResponses: - def __init__(self, responses: List[Dict[str, Any]]) -> None: - self._responses = list(responses) - self.requests: List[Dict[str, Any]] = [] - - async def send_async(self, **kwargs: Any) -> Any: - self.requests.append(kwargs) - return self._responses.pop(0) - - -class QueuedClient: - def __init__(self, responses: List[Dict[str, Any]]) -> None: - self.beta = type("Beta", (), {"responses": QueuedResponses(responses)})() - - -def function_call_item(call_id: str, name: str, arguments: str) -> Dict[str, Any]: - return {"type": "function_call", "id": f"fc_{call_id}", "callId": call_id, "name": name, "arguments": arguments} - - -def text_response(response_id: str, text: str) -> Dict[str, Any]: - return { - "id": response_id, - "output": [{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": text}]}], - } - - -def make_response(response_id: str, output: List[Dict[str, Any]]) -> Dict[str, Any]: - return {"id": response_id, "output": output} +from tests._fixtures import QueuedClient, function_call_item, make_response, text_response def empty_response(response_id: str = "resp_empty") -> Dict[str, Any]: - return {"id": response_id, "output": []} + """A response with no output items -- the empty-final case under test.""" + return make_response(response_id, []) auto_tool = tool( @@ -65,7 +37,7 @@ async def test_stops_loop_instead_of_orphaned_function_call_followup() -> None: response = await result.get_response() assert response["id"] == "resp_mixed" - assert len(client.beta.responses.requests) == 1 + assert len(client.requests) == 1 async def test_still_loops_when_every_call_in_the_round_resolves() -> None: @@ -80,9 +52,10 @@ async def test_still_loops_when_every_call_in_the_round_resolves() -> None: text = await result.get_text() assert text == "All done." - assert len(client.beta.responses.requests) == 2 - followup_input = client.beta.responses.requests[1]["input"] + assert len(client.requests) == 2 + followup_input = client.requests[1]["input"] fn_call_output = next((i for i in followup_input if i.get("type") == "function_call_output"), None) + assert fn_call_output is not None # On the wire the SDK's snake_case spelling is used (internal items keep # upstream's camelCase callId; _send converts at the transport boundary). assert fn_call_output["call_id"] == "call_auto_1" @@ -102,7 +75,7 @@ async def test_retries_once_then_accepts_empty_final_after_a_completed_tool_roun text = await result.get_text() assert text == "" - assert len(client.beta.responses.requests) == 3 + assert len(client.requests) == 3 response = await result.get_response() assert response["id"] == "resp_empty_2" @@ -121,7 +94,7 @@ async def test_returns_text_when_the_empty_final_retry_succeeds() -> None: text = await call_model(client, {"model": "test-model", "input": "review", "tools": [post_comment_tool]}).get_text() assert text == "Done posting." - assert len(client.beta.responses.requests) == 3 + assert len(client.requests) == 3 async def test_retry_forces_tool_choice_none_while_keeping_tools_in_request() -> None: @@ -135,11 +108,11 @@ async def test_retry_forces_tool_choice_none_while_keeping_tools_in_request() -> await call_model(client, {"model": "test-model", "input": "review", "tools": [post_comment_tool]}).get_text() - followup_request = client.beta.responses.requests[1] + followup_request = client.requests[1] assert "tools" in followup_request assert followup_request.get("tool_choice") != "none" - retry_request = client.beta.responses.requests[2] + retry_request = client.requests[2] assert "tools" in retry_request assert retry_request["tool_choice"] == "none" assert retry_request["input"] == followup_request["input"] @@ -169,7 +142,7 @@ async def test_throws_on_empty_final_when_strict_final_response_is_true() -> Non assert "Invalid final response: empty or invalid output" in str(error) assert raised - assert len(client.beta.responses.requests) == 2 + assert len(client.requests) == 2 async def test_still_throws_on_empty_output_when_no_tool_rounds_completed() -> None: @@ -182,7 +155,7 @@ async def test_still_throws_on_empty_output_when_no_tool_rounds_completed() -> N raised = True assert raised - assert len(client.beta.responses.requests) == 1 + assert len(client.requests) == 1 async def test_does_not_send_client_only_fields_to_the_api() -> None: @@ -198,7 +171,7 @@ async def test_does_not_send_client_only_fields_to_the_api() -> None: }, ).get_text() - request = client.beta.responses.requests[0] + request = client.requests[0] for key in ( "strict_final_response", "allow_final_response", diff --git a/tests/unit/test_turn_end_race_condition.py b/tests/unit/test_turn_end_race_condition.py new file mode 100644 index 0000000..627264d --- /dev/null +++ b/tests/unit/test_turn_end_race_condition.py @@ -0,0 +1,161 @@ +"""turn.end must never be silently dropped. + +Ports `packages/agent/tests/unit/turn-end-race-condition.test.ts`. + +Upstream's bug: `startTurnBroadcasterExecution()` called `broadcaster.complete()` +without awaiting the pipe that was still draining the response stream, so the +`turn.end` pushed afterwards was silently discarded. The root cause is a +*contract* on `ToolEventBroadcaster`, not a defect in it: `push()` after +`complete()` is a no-op, so every caller must finish pushing before completing. + +Two layers are covered, because either alone is insufficient: + +1. The broadcaster contract (`push` after `complete` drops) and the two calling + patterns — fire-and-forget vs. await-then-complete. These pin the mechanism so + a future refactor toward the buggy shape fails here. +2. The **production** invariant through `call_model`. This port does not use + upstream's fire-and-forget pipe — `ModelResult._run` appends turn events + sequentially (`model_result.py:648-672`) — so the mechanism tests alone would + pass even if the real loop stopped emitting `turn.end`. Layer 2 is what + actually guards shipped behavior. + +Determinism: upstream races a 20ms stream tick against a 5ms executor. Ported as +timing, it would flip under CI load. Here the stream is gated on an +`asyncio.Event` so ordering is explicit, not hoped for. Async primitives are +constructed inside the test body: `asyncio.Condition()` binds the running loop +eagerly on 3.9 and lazily on 3.13, so module-scope construction is a +version-specific landmine. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, AsyncIterator, Dict, List + +from openrouter_agent import call_model, tool +from openrouter_agent.reusable_stream import ReusableReadableStream +from openrouter_agent.tool_event_broadcaster import ToolEventBroadcaster +from tests._fixtures import QueuedClient, text_response, tool_call_response + + +async def _gated_stream(events: List[Dict[str, Any]], gate: asyncio.Event) -> AsyncIterator[Dict[str, Any]]: + """Yield nothing until `gate` is set, so the pipe is provably mid-drain.""" + await gate.wait() + for event in events: + yield event + + +# -- Layer 1: the broadcaster contract ------------------------------------------ + + +async def test_broadcaster_silently_drops_events_pushed_after_complete() -> None: + """Characterization: this is the mechanism behind the dropped turn.end.""" + broadcaster = ToolEventBroadcaster() + consumer = broadcaster.create_consumer() + + broadcaster.push({"type": "turn.start"}) + broadcaster.push({"type": "event1"}) + broadcaster.complete() + broadcaster.push({"type": "turn.end"}) # silently dropped + + collected = [event async for event in consumer] + + assert [event["type"] for event in collected] == ["turn.start", "event1"] + assert [event for event in collected if event["type"] == "turn.end"] == [] + + +async def test_buggy_pattern_drops_turn_end_when_complete_precedes_pipe() -> None: + """Fire-and-forget pipe + early complete() loses turn.end. Documents the bug.""" + broadcaster = ToolEventBroadcaster() + gate = asyncio.Event() + stream = ReusableReadableStream( + _gated_stream([{"type": "response.output_text.delta"}, {"type": "response.completed"}], gate) + ) + + async def pipe() -> None: + broadcaster.push({"type": "turn.start", "turnNumber": 0}) + async for event in stream.create_consumer(): + broadcaster.push(event) + broadcaster.push({"type": "turn.end", "turnNumber": 0}) + + pipe_task = asyncio.create_task(pipe()) + consumer = broadcaster.create_consumer() + + # Let the pipe push turn.start and block on the gated stream. The pipe is now + # unambiguously unfinished — no sleep required. + await asyncio.sleep(0) + + # BUG: complete() without awaiting pipe_task. + broadcaster.complete() + + collected = [event async for event in consumer] + + gate.set() + await pipe_task + + assert len([e for e in collected if e["type"] == "turn.start"]) == 1 + assert len([e for e in collected if e["type"] == "turn.end"]) == 0, ( + "turn.end should be dropped by the buggy pattern; if this now survives, " + "the broadcaster's push-after-complete contract changed" + ) + + +async def test_fixed_pattern_preserves_turn_end_when_pipe_is_awaited() -> None: + """Awaiting the pipe before complete() keeps turn.end, ordered, with turnNumber.""" + broadcaster = ToolEventBroadcaster() + gate = asyncio.Event() + stream = ReusableReadableStream( + _gated_stream([{"type": "response.output_text.delta"}, {"type": "response.completed"}], gate) + ) + + async def pipe() -> None: + broadcaster.push({"type": "turn.start", "turnNumber": 0}) + async for event in stream.create_consumer(): + broadcaster.push(event) + broadcaster.push({"type": "turn.end", "turnNumber": 0}) + + pipe_task = asyncio.create_task(pipe()) + consumer = broadcaster.create_consumer() + + async def execute_then_complete() -> None: + gate.set() + await pipe_task # THE FIX: let turn.end land before completing + broadcaster.complete() + + execution = asyncio.create_task(execute_then_complete()) + collected = [event async for event in consumer] + await execution + + types = [event["type"] for event in collected] + assert types.count("turn.start") == 1 + assert types.count("turn.end") == 1 + assert types.index("turn.start") < types.index("turn.end") + + # camelCase matches upstream; this port emits `turn_number` alongside it + # (model_result.py:651-652). + turn_start = next(event for event in collected if event["type"] == "turn.start") + turn_end = next(event for event in collected if event["type"] == "turn.end") + assert turn_start["turnNumber"] == 0 + assert turn_end["turnNumber"] == 0 + + +# -- Layer 2: the production invariant ------------------------------------------ + + +async def test_call_model_emits_exactly_one_paired_turn_end_per_turn() -> None: + """Every turn.start is matched by exactly one turn.end, in order, across turns. + + A tool round plus a final turn means two of each. Asserting count and order — + not mere membership — is the point: a membership check passes even if turn.end + is emitted twice, never, or before turn.start. + """ + client = QueuedClient([tool_call_response("r1", "echo"), text_response("r2", "done")]) + echo = tool(name="echo", input_schema=dict, output_schema=dict, execute=lambda params, ctx: {"ok": True}) + + result = call_model(client, {"model": "test-model", "input": "go", "tools": [echo]}) + turn_events = [ + event async for event in result.get_full_responses_stream() if str(event["type"]).startswith("turn.") + ] + + assert [event["type"] for event in turn_events] == ["turn.start", "turn.end", "turn.start", "turn.end"] + assert [event["turnNumber"] for event in turn_events] == [0, 0, 1, 1] diff --git a/uv.lock b/uv.lock index d2bad58..a006541 100644 --- a/uv.lock +++ b/uv.lock @@ -69,6 +69,232 @@ 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 = "coverage" +version = "7.10.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, + { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, + { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, + { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, + { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, + { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, + { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, + { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, + { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, + { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, + { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, + { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, + { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, + { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, + { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli" }, +] + +[[package]] +name = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.10' and python_full_version < '3.15'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/060ce69008ac97bbc01b1411b3e55b61f6f015659400b46749b662107831/coverage-7.15.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d", size = 221284, upload-time = "2026-07-15T18:53:29.52Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a3/d936e8b53edd9684100a6aefaf3fcabaa54728fe33324436c8d279c047aa/coverage-7.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846", size = 221799, upload-time = "2026-07-15T18:53:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a3/ca234b06aec7ee28226f11d39a696b4481fe5eddfce8e03bf39979bb8ffb/coverage-7.15.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf", size = 248544, upload-time = "2026-07-15T18:53:33.212Z" }, + { url = "https://files.pythonhosted.org/packages/2b/89/dda79527bb7573ba91828b2fb91b3105d87378d6a2749ca0c0924ce0addd/coverage-7.15.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376", size = 250374, upload-time = "2026-07-15T18:53:34.683Z" }, + { url = "https://files.pythonhosted.org/packages/67/c6/c33755a34572f81f49a8c0cdf6b622f35ccb3238b136e1909daf0cdd4319/coverage-7.15.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd", size = 252239, upload-time = "2026-07-15T18:53:36.205Z" }, + { url = "https://files.pythonhosted.org/packages/b9/6f/dc341741b375be53a5baeee5b4bf0f0e525d38caed428f7932d23bb7bcb1/coverage-7.15.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb", size = 254150, upload-time = "2026-07-15T18:53:37.863Z" }, + { url = "https://files.pythonhosted.org/packages/e9/8d/966a18a5b195cb4e77b14c53f5f3dce22b5da05e6de7fafd1e08f2d2067a/coverage-7.15.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1", size = 249234, upload-time = "2026-07-15T18:53:39.394Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/8b2e367496ab48484d48e79984fec76cdc1b7cb5d3a00ee799a5602e3ec9/coverage-7.15.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9", size = 250276, upload-time = "2026-07-15T18:53:41.027Z" }, + { url = "https://files.pythonhosted.org/packages/63/92/1199318a200eb6c8c6ce0192c892c8710ac791abbe0f35099294620bbfda/coverage-7.15.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a", size = 248283, upload-time = "2026-07-15T18:53:42.557Z" }, + { url = "https://files.pythonhosted.org/packages/56/da/be284a55c5619bda891a89c27dfd59324a2c6a14d755cf6aac6960ceebeb/coverage-7.15.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287", size = 252093, upload-time = "2026-07-15T18:53:44.271Z" }, + { url = "https://files.pythonhosted.org/packages/d4/53/ee112da833ddd77b73c6d781a98029b45b584b136615b4900ed0569f887e/coverage-7.15.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89", size = 248552, upload-time = "2026-07-15T18:53:45.7Z" }, + { url = "https://files.pythonhosted.org/packages/82/6a/802cfc802e9113494c80bf3f284cd4d72faeb1f24e244f61046af364f2ca/coverage-7.15.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88", size = 249154, upload-time = "2026-07-15T18:53:47.256Z" }, + { url = "https://files.pythonhosted.org/packages/2c/65/529808e91d651147edae408fd9e894abc3b8cad7f3e594bbc36719a3e13a/coverage-7.15.2-cp310-cp310-win32.whl", hash = "sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443", size = 223334, upload-time = "2026-07-15T18:53:48.768Z" }, + { url = "https://files.pythonhosted.org/packages/68/0f/0e1829d7001130876dfbc0b4e1c737ea7c155b809e3e4a98a0aa268e2369/coverage-7.15.2-cp310-cp310-win_amd64.whl", hash = "sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629", size = 223959, upload-time = "2026-07-15T18:53:50.429Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, + { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, + { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, + { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, + { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -349,6 +575,7 @@ dev = [ { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, { name = "ruff" }, ] @@ -359,6 +586,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.11.2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4,<9.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23,<1.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0,<8.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14" }, ] provides-extras = ["dev"] @@ -576,6 +804,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694, upload-time = "2025-03-25T06:22:27.807Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, + { name = "coverage", version = "7.15.2", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "ruff" version = "0.15.20"