From 6d59f3b5a615a132be72f8ac350ed3de6e12971b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Rochel?= Date: Thu, 17 Sep 2026 11:09:57 +0200 Subject: [PATCH 1/2] chore(python): enforce repository quality standards --- .github/PULL_REQUEST_TEMPLATE.md | 2 + .github/workflows/python-quality.yml | 78 ++ .gitignore | 6 + .lintstagedrc.json | 1 + .prettierignore | 4 + AGENTS.md | 4 + CONTRIBUTING.md | 4 + docs/specs/README.md | 1 + docs/specs/observability-langfuse.md | 15 +- docs/specs/python-quality.md | 73 ++ package.json | 3 + .../.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 2 +- .../darrow-observability-langfuse/README.md | 4 +- .../backend/pyproject.toml | 42 + .../backend/scripts/__init__.py | 1 + .../backend/scripts/compare_benchmark.py | 39 + .../backend/scripts/verify_coverage.py | 48 + .../darrow_observability_langfuse/capture.py | 800 ++++++++++----- .../src/darrow_observability_langfuse/cli.py | 279 ++++-- .../darrow_observability_langfuse/config.py | 26 +- .../darrow_observability_langfuse/context.py | 28 +- .../darrow_observability_langfuse/delivery.py | 229 +++-- .../darrow_observability_langfuse/export.py | 416 +++++--- .../lifecycle.py | 196 +++- .../darrow_observability_langfuse/py.typed | 0 .../darrow_observability_langfuse/rollout.py | 923 +++++++++++------- .../darrow_observability_langfuse/sidecar.py | 198 ++-- .../backend/tests/benchmark_capture.py | 370 +++++-- .../backend/tests/smoke_ingestion.py | 32 +- .../backend/tests/test_capture.py | 147 ++- .../backend/tests/test_cli.py | 145 ++- .../backend/tests/test_config.py | 50 +- .../backend/tests/test_delivery.py | 341 +++++-- .../backend/tests/test_edge_cases.py | 771 +++++++++++++++ .../backend/tests/test_export.py | 111 ++- .../backend/tests/test_lifecycle.py | 326 +++++-- .../backend/tests/test_privacy.py | 121 ++- .../backend/tests/test_properties.py | 62 ++ .../backend/tests/test_quality_gate.py | 37 + .../backend/tests/test_recovery_scope.py | 175 +++- .../backend/tests/test_sidecar.py | 43 +- .../backend/uv.lock | 433 ++++++++ .../configure-langfuse-observability/SKILL.md | 14 +- .../evals/work-item-precedence.yaml | 16 +- .../tests/fresh-install.test.sh | 28 + python-packages.txt | 2 + scripts/check-python | 44 + scripts/check-python-inventory | 43 + scripts/check-python-performance | 18 + scripts/test-python-inventory | 23 + 51 files changed, 5208 insertions(+), 1568 deletions(-) create mode 100644 .github/workflows/python-quality.yml create mode 100644 docs/specs/python-quality.md create mode 100644 plugins/capability/darrow-observability-langfuse/backend/scripts/__init__.py create mode 100644 plugins/capability/darrow-observability-langfuse/backend/scripts/compare_benchmark.py create mode 100644 plugins/capability/darrow-observability-langfuse/backend/scripts/verify_coverage.py create mode 100644 plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/py.typed create mode 100644 plugins/capability/darrow-observability-langfuse/backend/tests/test_edge_cases.py create mode 100644 plugins/capability/darrow-observability-langfuse/backend/tests/test_properties.py create mode 100644 plugins/capability/darrow-observability-langfuse/backend/tests/test_quality_gate.py create mode 100755 plugins/capability/darrow-observability-langfuse/tests/fresh-install.test.sh create mode 100644 python-packages.txt create mode 100755 scripts/check-python create mode 100755 scripts/check-python-inventory create mode 100755 scripts/check-python-performance create mode 100755 scripts/test-python-inventory diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a89c755e..5369db5c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -46,3 +46,5 @@ and direct reviewers to the riskiest parts. Write "None" when there are none. behavior. - [ ] I confirmed that each changed plugin remains self-contained, or this change does not affect plugin content. +- [ ] I ran `bun run check:python`, or this change does not affect registered + Python packages or their repository quality infrastructure. diff --git a/.github/workflows/python-quality.yml b/.github/workflows/python-quality.yml new file mode 100644 index 00000000..4e25a7cd --- /dev/null +++ b/.github/workflows/python-quality.yml @@ -0,0 +1,78 @@ +name: Python quality + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + package: + name: Python ${{ matrix.python-version }} on ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ["3.10", "3.11", "3.12", "3.13"] + runs-on: ${{ matrix.os }} + env: + UV_PYTHON: ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + - run: scripts/check-python + + inventory: + name: Python inventory guard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: scripts/test-python-inventory + + fresh-install: + name: Python fresh plugin install + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - uses: astral-sh/setup-uv@v6 + - run: bash plugins/capability/darrow-observability-langfuse/tests/fresh-install.test.sh + + performance: + name: Python performance invariants + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - uses: astral-sh/setup-uv@v6 + - run: scripts/check-python-performance + + aggregate: + name: Python quality + if: always() + needs: [package, inventory, fresh-install, performance] + runs-on: ubuntu-latest + steps: + - name: Require every Python quality job + env: + PACKAGE_RESULT: ${{ needs.package.result }} + INVENTORY_RESULT: ${{ needs.inventory.result }} + INSTALL_RESULT: ${{ needs.fresh-install.result }} + PERFORMANCE_RESULT: ${{ needs.performance.result }} + run: | + test "$PACKAGE_RESULT" = success + test "$INVENTORY_RESULT" = success + test "$INSTALL_RESULT" = success + test "$PERFORMANCE_RESULT" = success diff --git a/.gitignore b/.gitignore index 803f7174..9084c568 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,12 @@ evals/results/ evals/corpus/orchestration/cache/ .tmp/ .DS_Store +**/.venv/ +**/__pycache__/ +**/.pytest_cache/ +**/.hypothesis/ +**/.coverage +**/coverage.json # Share Codex configuration without tracking other local Codex state. !.codex/ diff --git a/.lintstagedrc.json b/.lintstagedrc.json index ee7a6360..24c5715b 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,6 +1,7 @@ { "*": "prettier --check --ignore-unknown --no-error-on-unmatched-pattern", "*.ts": "eslint --no-warn-ignored --max-warnings 0", + "**/{*.py,pyproject.toml,uv.lock}": "scripts/check-python --fast", "*.{sh,bash}": "scripts/lint-shell", ".githooks/!(*.*)": "scripts/lint-shell", "scripts/!(*.*)": "scripts/lint-shell", diff --git a/.prettierignore b/.prettierignore index 94209b83..2a6be010 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,8 @@ plugins/*/*/skills/*/SKILL.md **/.venv/** **/__pycache__/** +**/.pytest_cache/** +**/.hypothesis/** +**/.coverage +**/coverage.json .worktrees/ diff --git a/AGENTS.md b/AGENTS.md index cfdda6cb..803efe9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,10 @@ Review agents must not run Git or GitHub commands against this repository. Before creating, changing, or running evals, read [`docs/eval-development.md`](docs/eval-development.md). +All Python packages follow [`docs/specs/python-quality.md`](docs/specs/python-quality.md). +Run `bun run check:python` after changing Python source, tests, project metadata, +or locks. + ## Git - Use imperative Conventional Commits without trailing periods. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9697f6c1..1c917de1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,11 +88,15 @@ bun run lint:shell bun run typecheck bun run check:decisions bun run check:docs +bun run check:python ``` `check:decisions` rejects missing or stale checked-in ADR catalog data. When ADRs change, refresh it first with `bash plugins/foundation/darrow-decisions/bin/decision catalog rebuild --repo .`. +`check:python` is required when Python source, tests, package metadata, or locks +change; its standards and package inventory are defined in +[`docs/specs/python-quality.md`](docs/specs/python-quality.md). Run script tests with both `bash` and `/bin/bash`. Run relevant evals with: diff --git a/docs/specs/README.md b/docs/specs/README.md index 17caa570..6180ac65 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -17,6 +17,7 @@ These files define current invariants. Start with the - [Code review](code-review.md): independent review and repair verification. - [Verification](verification.md): replaceable assessment and criterion-level evidence. - [Langfuse observability](observability-langfuse.md): Codex telemetry and privacy. +- [Repository Python quality](python-quality.md): package inventory, checks, tests, and release evidence. - [Marketplace installation](marketplace-installation.md): inventory-driven host installation shortcuts. - [Adaptive Delivery](adaptive-delivery.md): one explicit execution owner. - [Ticket to PR](ticket-to-pr.md): the delivery recipe. diff --git a/docs/specs/observability-langfuse.md b/docs/specs/observability-langfuse.md index ebb4664d..37ae55ef 100644 --- a/docs/specs/observability-langfuse.md +++ b/docs/specs/observability-langfuse.md @@ -270,7 +270,16 @@ continuity, missing-snapshot quarantine, epoch session segmentation, detached and non-ticket Git state, rollout reconstruction, deduplication, content privacy, malformed input, missing runtime or configuration, and exporter failure. Backend checks run through UV. Portable hook-launcher tests run with -both supported Bash executables. +both supported Bash executables. The backend conforms to the repository-wide +[Python quality standard](python-quality.md), including separate 95% statement +and branch coverage gates on every supported Python and CI platform. + +Performance evidence distinguishes startup from steady-state foreground +capture, confirms network-independent foreground completion, bounded +incremental reads and exporter batching, and reports peak memory for a stated +workload. Release evidence includes a fresh artifact install with runtime-only +locked dependencies. The isolated live Langfuse smoke test remains the release +check for actual ingestion. Participant-visible colocated evals cover configuration/help intent, refusal to claim tracing without prerequisites, privacy disclosure, work-item precedence, @@ -314,3 +323,7 @@ trace and work-item metadata. 8. **OLF-P8 — Real ingestion.** When Docker infrastructure is available, an isolated official Langfuse Compose deployment accepts and exposes a trace emitted by the plugin. +9. **OLF-P9 — Python quality.** The backend passes the registered-package + formatting, lint, strict typing, deterministic tests, separate statement + and branch coverage, supported-platform CI, performance, and fresh-install + obligations in the repository Python quality standard. diff --git a/docs/specs/python-quality.md b/docs/specs/python-quality.md new file mode 100644 index 00000000..0e3b4b8f --- /dev/null +++ b/docs/specs/python-quality.md @@ -0,0 +1,73 @@ +# Repository Python quality + +## Purpose + +Python in this repository must remain readable, typed, deterministic, and safe +to release across its declared support range. These rules apply to every Python +package listed in [`python-packages.txt`](../../python-packages.txt). A new +Python source file, `pyproject.toml`, or UV lock outside a registered package is +an error until its package is added to that inventory. + +## Source quality + +Packages use Ruff for formatting, import order, common correctness rules, +modern syntax, naming, simplification, and a cyclomatic-complexity ceiling of +five. Mypy runs in strict mode over production code, tests, and package tooling. +Development tools belong in a locked development dependency group and must not +enter the runtime dependency set. + +Names describe the domain concept or action they represent. A module owns one +coherent responsibility; a function performs one focused operation at one +level of abstraction. Boundary code validates untrusted input and reports a +specific error. An exception may be translated when the new error adds useful +domain context, but it must retain its cause. Broad catches are allowed only at +an explicit process or protocol boundary with documented failure semantics. + +Automation cannot judge every aspect of those rules. Reviewers inspect names, +module cohesion, function focus, error boundaries, and whether assertions +verify observable behavior rather than implementation details. + +## Tests and coverage + +Each package keeps fast unit tests for local behavior and integration tests for +the real filesystem, database, subprocess, or protocol seams it owns. Every bug +fix carries a regression test that fails for the reported trigger. Property +tests are required where generated cases can test a meaningful invariant, such +as parser normalization, validation, redaction, serialization round trips, or +state-machine transitions. They use deterministic settings suitable for CI. + +Coverage is measured for the complete import package with branch measurement +enabled. The aggregate gate calculates statement coverage and branch coverage +separately for each registered package; both must be at least 95%. The gate +must fail when statement coverage passes but branch coverage does not. Coverage +exclusions require a concrete unreachable-platform or interpreter reason in +the source and review. + +## Commands and enforcement + +`bun run check:python` is the canonical local and CI command. For every +registered package it verifies the lock, synchronizes the complete development +environment, checks formatting and lint, runs strict typing, executes all tests +including property tests, and enforces both coverage measures. The pre-commit +hook runs the fast formatting, lint, and type portion when staged Python or +Python project files change. + +CI runs the canonical gate on Linux and macOS for every declared Python minor +version. A stable aggregate `Python quality` status is the branch-protection +check. The inventory guard itself is tested by introducing a temporary +unregistered package and proving that the gate refuses it. + +## Performance and release evidence + +Performance checks target observable workload invariants rather than arbitrary +timing budgets. A benchmark identifies its fixture size and environment, +reports startup and steady-state foreground capture separately, confirms that +foreground capture does not depend on the network, checks bounded incremental +rollout reads and exporter batching, and reports peak memory. A numeric +regression threshold may be added only after representative measurements make +the budget defensible. + +Release validation installs the plugin from a fresh copied artifact using only +locked runtime dependencies before exercising its launcher and backend. The +isolated live Langfuse ingestion procedure remains release evidence for the +external service boundary; it is not replaced by mocked CI tests. diff --git a/package.json b/package.json index 6e082626..6d2c6493 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,9 @@ "check:docs": "bun scripts/check-docs.ts", "check:docs:external": "bun scripts/check-docs.ts --external", "check:decisions": "bash plugins/foundation/darrow-decisions/bin/decision validate --repo .", + "check:python": "scripts/check-python", + "check:python:inventory": "scripts/test-python-inventory", + "check:python:performance": "scripts/check-python-performance", "eval": "bun evals/runner/run.ts", "eval:coverage": "bun evals/runner/coverage.ts", "eval:orchestration:prepare": "bun evals/corpus/orchestration/prepare.ts", diff --git a/plugins/capability/darrow-observability-langfuse/.claude-plugin/plugin.json b/plugins/capability/darrow-observability-langfuse/.claude-plugin/plugin.json index 48c2a53d..9477cf63 100644 --- a/plugins/capability/darrow-observability-langfuse/.claude-plugin/plugin.json +++ b/plugins/capability/darrow-observability-langfuse/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "darrow-observability-langfuse", "description": "Export Codex rollout turns to Langfuse with attribution epochs", - "version": "0.4.2", + "version": "0.5.0", "license": "BUSL-1.1", "author": { "name": "Björn Rochel", diff --git a/plugins/capability/darrow-observability-langfuse/.codex-plugin/plugin.json b/plugins/capability/darrow-observability-langfuse/.codex-plugin/plugin.json index 37a0cf8c..11e8ab4e 100644 --- a/plugins/capability/darrow-observability-langfuse/.codex-plugin/plugin.json +++ b/plugins/capability/darrow-observability-langfuse/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-observability-langfuse", - "version": "0.4.2", + "version": "0.5.0", "description": "Export Codex rollout turns to Langfuse with attribution epochs", "author": { "name": "Björn Rochel", diff --git a/plugins/capability/darrow-observability-langfuse/README.md b/plugins/capability/darrow-observability-langfuse/README.md index a03aabe6..a56b6597 100644 --- a/plugins/capability/darrow-observability-langfuse/README.md +++ b/plugins/capability/darrow-observability-langfuse/README.md @@ -264,10 +264,12 @@ bash hooks/stop.test.sh bash hooks/export-failure.test.sh bash hooks/refusal.test.sh bash hooks/strict.test.sh -uv run --frozen --project backend python -m unittest discover -s backend/tests +bun run check:python ``` Run each shell test with both `bash` and `/bin/bash` in repository development. +The repository command verifies the UV lock, formatting, lint, strict typing, +tests, property tests, and separate statement and branch coverage gates. ## When to use diff --git a/plugins/capability/darrow-observability-langfuse/backend/pyproject.toml b/plugins/capability/darrow-observability-langfuse/backend/pyproject.toml index 55f8ba69..f7bc392c 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/pyproject.toml +++ b/plugins/capability/darrow-observability-langfuse/backend/pyproject.toml @@ -8,9 +8,51 @@ dependencies = [ "opentelemetry-exporter-otlp-proto-http>=1.36,<2", ] +[dependency-groups] +dev = [ + "coverage[toml]>=7.10,<8", + "hypothesis>=6.138,<7", + "mypy>=1.18,<2", + "pytest>=8.4,<9", + "ruff>=0.12,<1", + "types-protobuf>=6.32,<7", +] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" +[tool.uv] +default-groups = [] + [tool.hatch.build.targets.wheel] packages = ["src/darrow_observability_langfuse"] + +[tool.ruff] +target-version = "py310" +line-length = 88 +src = ["src", "tests", "scripts"] + +[tool.ruff.lint] +select = ["B", "C4", "C90", "E4", "E7", "E9", "F", "I", "N", "RUF", "SIM", "UP"] +mccabe.max-complexity = 5 + +[tool.ruff.format] +docstring-code-format = true + +[tool.mypy] +python_version = "3.10" +strict = true +files = ["src", "tests", "scripts"] + +[tool.pytest.ini_options] +addopts = ["--strict-config", "--strict-markers"] +testpaths = ["tests"] + +[tool.coverage.run] +branch = true +source = ["darrow_observability_langfuse"] + +[tool.coverage.report] +show_missing = true +skip_covered = false diff --git a/plugins/capability/darrow-observability-langfuse/backend/scripts/__init__.py b/plugins/capability/darrow-observability-langfuse/backend/scripts/__init__.py new file mode 100644 index 00000000..bf076b3c --- /dev/null +++ b/plugins/capability/darrow-observability-langfuse/backend/scripts/__init__.py @@ -0,0 +1 @@ +"""Development-only quality gate helpers.""" diff --git a/plugins/capability/darrow-observability-langfuse/backend/scripts/compare_benchmark.py b/plugins/capability/darrow-observability-langfuse/backend/scripts/compare_benchmark.py new file mode 100644 index 00000000..c8b31fe6 --- /dev/null +++ b/plugins/capability/darrow-observability-langfuse/backend/scripts/compare_benchmark.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + + +def _load(path: Path) -> dict[str, Any]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError(f"benchmark output is not an object: {path}") + return value + + +def verify(control: dict[str, Any], candidate: dict[str, Any]) -> None: + matched = ("turns", "input_sha256", "observations", "acknowledged_turns") + for key in matched: + if control.get(key) != candidate.get(key): + raise ValueError(f"benchmark workloads differ for {key}") + if candidate["bytes_read"] >= control["bytes_read"]: + raise ValueError("candidate incremental reads are not bounded below control") + if candidate["exporter_requests"] > control["exporter_requests"]: + raise ValueError("candidate exporter batching regressed") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("control", type=Path) + parser.add_argument("candidate", type=Path) + arguments = parser.parse_args() + control = _load(arguments.control) + candidate = _load(arguments.candidate) + verify(control, candidate) + print(json.dumps({"control": control, "candidate": candidate}, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/plugins/capability/darrow-observability-langfuse/backend/scripts/verify_coverage.py b/plugins/capability/darrow-observability-langfuse/backend/scripts/verify_coverage.py new file mode 100644 index 00000000..0cefc4f3 --- /dev/null +++ b/plugins/capability/darrow-observability-langfuse/backend/scripts/verify_coverage.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + + +def _percentage(covered: int, total: int) -> float: + return 100.0 if total == 0 else covered * 100.0 / total + + +def coverage_percentages(report: dict[str, Any]) -> tuple[float, float]: + totals = report.get("totals") + if not isinstance(totals, dict): + raise ValueError("coverage report has no totals object") + try: + lines = _percentage(totals["covered_lines"], totals["num_statements"]) + branches = _percentage(totals["covered_branches"], totals["num_branches"]) + except (KeyError, TypeError, ZeroDivisionError) as error: + raise ValueError("coverage report has invalid totals") from error + return lines, branches + + +def verify(report: dict[str, Any], minimum: float) -> tuple[float, float]: + lines, branches = coverage_percentages(report) + failures = [] + if lines < minimum: + failures.append(f"line coverage {lines:.2f}%") + if branches < minimum: + failures.append(f"branch coverage {branches:.2f}%") + if failures: + raise ValueError(f"{', '.join(failures)} below required {minimum:.2f}%") + return lines, branches + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("report", type=Path) + parser.add_argument("--minimum", type=float, default=95.0) + arguments = parser.parse_args() + report = json.loads(arguments.report.read_text(encoding="utf-8")) + lines, branches = verify(report, arguments.minimum) + print(f"coverage passed: lines={lines:.2f}% branches={branches:.2f}%") + + +if __name__ == "__main__": + main() diff --git a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/capture.py b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/capture.py index 3c591af2..5b97bfe7 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/capture.py +++ b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/capture.py @@ -1,4 +1,5 @@ """Local, transactional rollout indexing and immutable delivery envelopes.""" + from __future__ import annotations import base64 @@ -6,27 +7,33 @@ import json import os import sqlite3 +from collections.abc import Callable, Iterator from contextlib import contextmanager from dataclasses import replace from pathlib import Path -from typing import Any +from typing import Any, cast from .config import Config from .context import delivery_context, require_context -from .lifecycle import bind_rollout_context, file_identity, register_rollout, terminal_records +from .lifecycle import ( + bind_rollout_context, + file_identity, + register_rollout, + terminal_records, +) from .rollout import attribution_snapshot, parse_rollout, trace_document -from .sidecar import (_load_state, load_provisional_attribution_snapshots, - discard_provisional_attribution_snapshots) +from .sidecar import ( + _load_state, + discard_provisional_attribution_snapshots, + load_provisional_attribution_snapshots, +) PARSER_VERSION = 2 -def _redact_state(value, config): +def _redact_state(value: Any, config: Config) -> Any: if isinstance(value, str): - for credential in (config.public_key, config.secret_key): - if credential: - value = value.replace(credential, "[REDACTED]") - return value + return _redact_string(value, config) if isinstance(value, list): return [_redact_state(item, config) for item in value] if isinstance(value, dict): @@ -34,14 +41,19 @@ def _redact_state(value, config): return value -def _private_cursor(cursor, config): - result = _redact_state(cursor, config) +def _redact_string(value: str, config: Config) -> str: + for credential in filter(None, (config.public_key, config.secret_key)): + value = value.replace(credential, "[REDACTED]") + return value + + +def _private_cursor(cursor: dict[str, Any], config: Config) -> dict[str, Any]: + result = cast("dict[str, Any]", _redact_state(cursor, config)) # JSONL tail bytes are parser input, not base64 credentials. Redact the # decoded bytes as well, keeping the committed source offset unchanged. tail = base64.b64decode(cursor["tail"]) - for credential in (config.public_key, config.secret_key): - if credential: - tail = tail.replace(credential.encode(), b"[REDACTED]") + for credential in filter(None, (config.public_key, config.secret_key)): + tail = tail.replace(credential.encode(), b"[REDACTED]") result["tail"] = base64.b64encode(tail).decode() return result @@ -51,7 +63,7 @@ def database_path(rollout: Path) -> Path: @contextmanager -def database(rollout: Path): +def database(rollout: Path) -> Iterator[sqlite3.Connection]: path = database_path(rollout) descriptor = os.open(path, os.O_CREAT | os.O_RDWR, 0o600) os.close(descriptor) @@ -84,290 +96,582 @@ def database(rollout: Path): connection.close() -def _get(connection, key, default=None): +def _get(connection: sqlite3.Connection, key: str, default: Any = None) -> Any: row = connection.execute("SELECT value FROM state WHERE key=?", (key,)).fetchone() return json.loads(row[0]) if row else default -def _put(connection, key, value): - connection.execute("INSERT OR REPLACE INTO state VALUES (?,?)", (key, json.dumps(value))) +def _put(connection: sqlite3.Connection, key: str, value: Any) -> None: + connection.execute( + "INSERT OR REPLACE INTO state VALUES (?,?)", (key, json.dumps(value)) + ) -def observation_count(trace): - def children_count(children): +def observation_count(trace: dict[str, Any]) -> int: + def children_count(children: list[dict[str, Any]]) -> int: return sum(1 + children_count(child.get("children", [])) for child in children) + return 1 + children_count(trace.get("observations", [])) -def _read_incremental(path: Path, checkpoint: dict, consume, max_offset=None): +def _read_incremental( + path: Path, + checkpoint: dict[str, Any], + consume: Callable[[dict[str, Any], dict[str, Any], bool], None], + max_offset: int | None = None, +) -> tuple[int, bool]: """Stream appended complete JSONL records; retain only an incomplete line.""" if not path.is_absolute() or not path.is_file(): raise ValueError("transcript_path must name a readable absolute file") with path.open("rb") as handle: stat = os.fstat(handle.fileno()) identity = file_identity(stat) - reset = (checkpoint.get("version") != PARSER_VERSION - or checkpoint.get("identity") != identity - or stat.st_size < checkpoint.get("offset", 0)) - if reset: - checkpoint.clear() - checkpoint.update(version=PARSER_VERSION, identity=identity, offset=0, tail="", parser={}) + reset = _prepare_checkpoint(checkpoint, identity, stat.st_size) handle.seek(checkpoint["offset"]) pending = base64.b64decode(checkpoint["tail"], validate=True) bytes_read = 0 while True: - remaining = 65536 if max_offset is None else max(0, min(65536, max_offset - checkpoint["offset"])) + remaining = _remaining_bytes(checkpoint["offset"], max_offset) chunk = handle.read(remaining) if not chunk: break bytes_read += len(chunk) checkpoint["offset"] += len(chunk) - line_offset = checkpoint["offset"] - len(pending) - len(chunk) - lines = (pending + chunk).split(b"\n") - pending = lines.pop() - for line in lines: - line_offset += len(line) + 1 - checkpoint["record_offset"] = line_offset - try: - value = json.loads(line) - except (ValueError, UnicodeDecodeError): - continue - if isinstance(value, dict) and isinstance(value.get("payload"), dict): - consume(value, checkpoint, reset) + pending = _consume_lines(pending + chunk, checkpoint, consume, reset) checkpoint["tail"] = base64.b64encode(pending).decode("ascii") return bytes_read, reset -def delivery_rows(rollout: Path): +def _prepare_checkpoint( + checkpoint: dict[str, Any], identity: list[int], size: int +) -> bool: + reset = ( + checkpoint.get("version") != PARSER_VERSION + or checkpoint.get("identity") != identity + or size < checkpoint.get("offset", 0) + ) + if reset: + checkpoint.clear() + checkpoint.update( + version=PARSER_VERSION, identity=identity, offset=0, tail="", parser={} + ) + return reset + + +def _remaining_bytes(offset: int, max_offset: int | None) -> int: + return 65536 if max_offset is None else max(0, min(65536, max_offset - offset)) + + +def _consume_lines( + content: bytes, + checkpoint: dict[str, Any], + consume: Callable[[dict[str, Any], dict[str, Any], bool], None], + reset: bool, +) -> bytes: + lines = content.split(b"\n") + pending = lines.pop() + line_offset = checkpoint["offset"] - len(content) + for line in lines: + line_offset += len(line) + 1 + checkpoint["record_offset"] = line_offset + record = _decode_record(line) + if record is not None: + consume(record, checkpoint, reset) + return pending + + +def _decode_record(line: bytes) -> dict[str, Any] | None: + try: + value = json.loads(line) + except (ValueError, UnicodeDecodeError): + return None + if isinstance(value, dict) and isinstance(value.get("payload"), dict): + return value + return None + + +def delivery_rows(rollout: Path) -> list[dict[str, Any]]: with database(rollout) as connection: - return [dict(row) for row in connection.execute("SELECT * FROM envelopes ORDER BY rowid")] + return [ + dict(row) + for row in connection.execute("SELECT * FROM envelopes ORDER BY rowid") + ] -def load_capture_snapshots(rollout: Path): +def load_capture_snapshots(rollout: Path) -> dict[str, dict[str, Any]]: path = database_path(rollout) if not path.exists(): return {} connection = sqlite3.connect(f"{path.as_uri()}?mode=ro", uri=True) try: - return {row[0]: json.loads(row[1]) for row in connection.execute("SELECT * FROM snapshots")} + return cast( + "dict[str, dict[str, Any]]", + { + row[0]: json.loads(row[1]) + for row in connection.execute("SELECT * FROM snapshots") + }, + ) finally: connection.close() -def capture(rollout: Path, config: Config, cwd: str, session_id: str, - turn_id: str | None, plugin_data: Path | None) -> dict[str, int]: +def capture( + rollout: Path, + config: Config, + cwd: str, + session_id: str, + turn_id: str | None, + plugin_data: Path | None, +) -> dict[str, int]: if not rollout.is_absolute() or not rollout.is_file(): raise ValueError("transcript_path must name a readable absolute file") - # Validate installed evidence before opening or modifying the new store. - legacy = _load_state(rollout) canonical = rollout.resolve() - if canonical != rollout: - other = _load_state(canonical) - legacy = {"uploaded_turn_ids": list(set(legacy["uploaded_turn_ids"] + other["uploaded_turn_ids"])), - "attribution_snapshots": {**legacy["attribution_snapshots"], **other["attribution_snapshots"]}} - rollout = canonical - provisional = (load_provisional_attribution_snapshots(plugin_data, session_id) - if plugin_data is not None else {}) - terminal = list(terminal_records(rollout, session_id)) + legacy = _combined_legacy_state(rollout, canonical) + provisional = ( + load_provisional_attribution_snapshots(plugin_data, session_id) + if plugin_data is not None + else {} + ) + terminal = list(terminal_records(canonical, session_id)) context = delivery_context(config, cwd) for event in terminal: require_context(event.get("delivery_context"), context) - # Reserve before even creating SQLite's schema, so concurrent terminal - # hooks never need to inspect a partially initialized capture database. - bind_rollout_context(rollout, session_id, context) - with database(rollout) as connection: + bind_rollout_context(canonical, session_id, context) + with database(canonical) as connection: connection.execute("BEGIN IMMEDIATE") try: - saved_context = _get(connection, "delivery_context") - if saved_context is None: - # Only an empty store may acquire a binding. Pre-upgrade work - # has no trustworthy destination and stays local. - if any(connection.execute(f"SELECT 1 FROM {table} LIMIT 1").fetchone() - for table in ("state", "envelopes", "captured_turns", "receipts", "snapshots")): - require_context(None, context) - else: - require_context(saved_context, context) - if saved_context is None: - _put(connection, "delivery_context", context) - for identifier in legacy["uploaded_turn_ids"]: - connection.execute("INSERT OR IGNORE INTO legacy VALUES (?)", (identifier,)) - for identifier, snapshot in legacy["attribution_snapshots"].items(): - connection.execute("INSERT OR IGNORE INTO snapshots VALUES (?,?)", - (identifier, json.dumps(snapshot))) - connection.execute("INSERT OR IGNORE INTO receipts VALUES (?,?,?)", - (identifier, "legacy", int(config.capture_content))) - snapshots = {row[0]: json.loads(row[1]) for row in connection.execute("SELECT * FROM snapshots")} - effective = {**provisional, **snapshots} - checkpoint = _get(connection, "rollout", {}) - timeline = _get(connection, "attribution", {}) - total_bytes = 0 - rebuilt = False - promoted = set() - - def subagent_loader(path): - nonlocal total_bytes - key = f"subagent:{path}" - cursor = _get(connection, key, {}) - child_turns = [] - def consume_child(record, state, reset): - _, completed = parse_rollout([record], state=state["parser"], finalize=False) - child_turns.extend(completed) - count, reset = _read_incremental(path, cursor, consume_child) - total_bytes += count - saved = [] if reset else cursor.get("turns", []) - saved.extend(child_turns) - cursor["turns"] = saved - child_session, active = parse_rollout([], state=cursor["parser"], finalize=True) - _put(connection, key, _private_cursor(cursor, config)) - return child_session, saved + active - - def index_turn(turn, session): - identifier = turn.get("turn_id") - if not isinstance(identifier, str) or not identifier: - return - # Capture is independent of attribution finalization. A later - # hook must not freeze a predecessor's provisional evidence. - row = connection.execute("SELECT finalized FROM captured_turns WHERE turn_id=?", - (identifier,)).fetchone() - if row is not None and row[0]: - return - if row is not None and connection.execute( - "SELECT 1 FROM receipts WHERE turn_id=?", (identifier,) - ).fetchone(): - return - if identifier == turn_id or connection.execute( - "SELECT 1 FROM receipts WHERE turn_id=? AND kind IN ('Stop','legacy')", (identifier,) - ).fetchone(): - turn["completed"] = True - values = (json.dumps(_redact_state(turn, config)), - json.dumps(_redact_state(session, config)), checkpoint.get("record_offset", checkpoint["offset"]), identifier) - if row is None: - connection.execute("INSERT INTO captured_turns (turn,session,source_end,turn_id) VALUES (?,?,?,?)", values) - else: - connection.execute("UPDATE captured_turns SET turn=?,session=?,source_end=? WHERE turn_id=?", values) - - def materialize(identifier, trace): - snapshot = effective.get(identifier) - if snapshot is not None: - connection.execute("INSERT OR IGNORE INTO snapshots VALUES (?,?)", - (identifier, json.dumps(snapshot))) - promoted.add(identifier) - if connection.execute("SELECT 1 FROM legacy WHERE turn_id=?", (identifier,)).fetchone(): - return - identity = hashlib.sha256(f"{session_id}\0{identifier}".encode()).hexdigest() - trace["metadata"]["darrow.delivery_id"] = identity - trace["metadata"]["darrow.expected_observation_count"] = observation_count(trace) - document = json.dumps({"status": "pending", "traces": [trace]}, sort_keys=True, separators=(",", ":")) - connection.execute("INSERT OR IGNORE INTO envelopes VALUES (?,?,?,?,?,?,?)", - (identifier, identity, document, observation_count(trace), "pending", None, os.getpid())) - - def consume(record, state, reset): - nonlocal rebuilt - if reset and not rebuilt: - rebuilt = True - timeline.clear() - connection.execute("DELETE FROM indexed_turns") - connection.execute("DELETE FROM turn_starts") - connection.execute("DELETE FROM captured_turns") - if record.get("type") == "event_msg" and record["payload"].get("type") == "task_started": - identifier = record["payload"].get("turn_id") - if isinstance(identifier, str): - try: - connection.execute("INSERT INTO turn_starts VALUES (?)", (identifier,)) - except sqlite3.IntegrityError as error: - raise ValueError("hook turn_id does not identify exactly one rollout turn") from error - session, completed = parse_rollout([record], state=state["parser"], finalize=False) - if session.get("session_id") not in {None, session_id, _redact_state(session_id, config)}: - raise ValueError("rollout thread ID does not match the hook session") - for turn in completed: - index_turn(turn, session) - - def apply_terminal(event): - for pending in connection.execute("SELECT * FROM captured_turns WHERE finalized=0 AND source_end<=? ORDER BY rowid", (event["offset"],)): - identifier = pending["turn_id"] - if event["event"] == "Interrupt" and identifier != event["turn_id"]: - continue - if connection.execute("SELECT 1 FROM receipts WHERE turn_id=?", (identifier,)).fetchone(): - continue - snapshot = event["attribution_snapshots"].get(identifier) - if snapshot is not None: - snapshots[identifier] = snapshot - connection.execute("INSERT OR IGNORE INTO snapshots VALUES (?,?)", (identifier, json.dumps(snapshot))) - connection.execute("INSERT INTO receipts VALUES (?,?,?)", (identifier, event["event"], int(event["capture_content"]))) - turn = json.loads(pending["turn"]) - turn["completed"] = True - if event["event"] == "Interrupt": - turn["aborted"] = True - connection.execute("UPDATE captured_turns SET turn=? WHERE turn_id=?", (json.dumps(turn), identifier)) - - identity = file_identity(rollout.stat()) - boundaries = sorted((event for event in terminal if event["identity"] == identity), - key=lambda item: (item["offset"], item["event"] != "Interrupt")) - reset = False - for event in [*boundaries, None]: - limit = event["offset"] if event else None - # A terminal watermark is a parsing boundary, not merely a - # filter applied after a resumed turn has changed parser state. - count, did_reset = _read_incremental(rollout, checkpoint, consume, max_offset=limit) - total_bytes += count - reset = reset or did_reset - session, active = parse_rollout([], state=checkpoint["parser"], finalize=True) - for turn in active: - index_turn(turn, session) - if event: - apply_terminal(event) - if not session.get("session_id"): - raise ValueError("rollout is missing a valid Codex thread ID") - row = connection.execute("SELECT turn,finalized FROM captured_turns WHERE turn_id=?", (turn_id,)).fetchone() - if turn_id is not None and row is None: - raise ValueError("hook turn_id does not identify exactly one rollout turn") - if turn_id is not None and not connection.execute("SELECT 1 FROM receipts WHERE turn_id=?", (turn_id,)).fetchone(): - if turn_id not in snapshots: - snapshots[turn_id] = attribution_snapshot(config, cwd) - connection.execute("INSERT INTO snapshots VALUES (?,?)", (turn_id, json.dumps(snapshots[turn_id]))) - connection.execute("INSERT INTO receipts VALUES (?,?,?)", (turn_id, "Stop", int(config.capture_content))) - # A delayed Stop may name a turn already closed in parser state - # by a later task_started. Its own receipt completes that stored - # turn even when no task_complete record was ever appended. - if not row["finalized"]: - completed_turn = json.loads(row["turn"]) - completed_turn["completed"] = True - connection.execute("UPDATE captured_turns SET turn=? WHERE turn_id=?", - (json.dumps(completed_turn), turn_id)) - effective = {**provisional, **snapshots} - for receipt in connection.execute("SELECT turn_id FROM receipts WHERE kind IN ('Interrupt','SessionEnd')"): - if receipt[0] not in snapshots: - effective.pop(receipt[0], None) - while True: - row = connection.execute("SELECT * FROM captured_turns WHERE finalized=0 ORDER BY rowid LIMIT 1").fetchone() - if row is None: - break - identifier = row["turn_id"] - turn = json.loads(row["turn"]) - receipt = connection.execute("SELECT * FROM receipts WHERE turn_id=?", (identifier,)).fetchone() - uploaded = connection.execute("SELECT 1 FROM legacy WHERE turn_id=?", (identifier,)).fetchone() - if receipt is None and not turn.get("aborted") and not uploaded: - break - if not turn.get("completed"): - break - turn_config = replace(config, capture_content=bool(receipt["capture_content"])) if receipt else config - document = trace_document( - rollout, turn_config, cwd, attribution_snapshots=effective, - parsed=(json.loads(row["session"]), [turn]), attribution_state=timeline, - subagent_loader=subagent_loader, - ) - trace = document["traces"][0] - connection.execute("INSERT OR REPLACE INTO indexed_turns VALUES (?,?)", (identifier, json.dumps(trace))) - materialize(identifier, trace) - connection.execute("UPDATE captured_turns SET finalized=1,turn=NULL WHERE turn_id=?", (identifier,)) - _put(connection, "rollout", _private_cursor(checkpoint, config)) - _put(connection, "attribution", timeline) - _put(connection, "session_id", session_id) + transaction = _CaptureTransaction( + connection, + canonical, + config, + cwd, + session_id, + turn_id, + legacy, + provisional, + terminal, + context, + ) + result = transaction.run() connection.execute("COMMIT") except BaseException: connection.execute("ROLLBACK") raise if plugin_data is not None: - discard_provisional_attribution_snapshots(plugin_data, session_id, promoted) - register_rollout(rollout, session_id, plugin_data, context) - return {"bytes_read": total_bytes, "rebuilt": int(reset)} + discard_provisional_attribution_snapshots( + plugin_data, session_id, transaction.promoted + ) + register_rollout(canonical, session_id, plugin_data, context) + return result + + +def _combined_legacy_state(rollout: Path, canonical: Path) -> dict[str, Any]: + legacy = _load_state(rollout) + if canonical == rollout: + return legacy + other = _load_state(canonical) + return { + "uploaded_turn_ids": list( + set(legacy["uploaded_turn_ids"] + other["uploaded_turn_ids"]) + ), + "attribution_snapshots": { + **legacy["attribution_snapshots"], + **other["attribution_snapshots"], + }, + } + + +class _CaptureTransaction: + def __init__( + self, + connection: sqlite3.Connection, + rollout: Path, + config: Config, + cwd: str, + session_id: str, + turn_id: str | None, + legacy: dict[str, Any], + provisional: dict[str, dict[str, Any]], + terminal: list[dict[str, Any]], + context: dict[str, Any], + ) -> None: + self.connection = connection + self.rollout = rollout + self.config = config + self.cwd = cwd + self.session_id = session_id + self.turn_id = turn_id + self.legacy = legacy + self.provisional = provisional + self.terminal = terminal + self.context = context + self.snapshots: dict[str, dict[str, Any]] = {} + self.effective: dict[str, dict[str, Any]] = {} + self.checkpoint: dict[str, Any] = {} + self.timeline: dict[str, Any] = {} + self.session: dict[str, Any] = {} + self.total_bytes = 0 + self.rebuilt = False + self.reset = False + self.promoted: set[str] = set() + + def run(self) -> dict[str, int]: + self._bind_database_context() + self._import_legacy() + self._load_saved_state() + self._parse_boundaries() + self._validate_session() + self._record_stop() + self._refresh_effective_snapshots() + self._finalize_turns() + self._save_state() + return {"bytes_read": self.total_bytes, "rebuilt": int(self.reset)} + + def _bind_database_context(self) -> None: + saved = _get(self.connection, "delivery_context") + if saved is None and self._database_has_evidence(): + require_context(None, self.context) + if saved is not None: + require_context(saved, self.context) + else: + _put(self.connection, "delivery_context", self.context) + + def _database_has_evidence(self) -> bool: + tables = ("state", "envelopes", "captured_turns", "receipts", "snapshots") + return any( + self.connection.execute(f"SELECT 1 FROM {table} LIMIT 1").fetchone() + for table in tables + ) + + def _import_legacy(self) -> None: + for identifier in self.legacy["uploaded_turn_ids"]: + self.connection.execute( + "INSERT OR IGNORE INTO legacy VALUES (?)", (identifier,) + ) + for identifier, snapshot in self.legacy["attribution_snapshots"].items(): + self.connection.execute( + "INSERT OR IGNORE INTO snapshots VALUES (?,?)", + (identifier, json.dumps(snapshot)), + ) + self.connection.execute( + "INSERT OR IGNORE INTO receipts VALUES (?,?,?)", + (identifier, "legacy", int(self.config.capture_content)), + ) + + def _load_saved_state(self) -> None: + self.snapshots = { + row[0]: json.loads(row[1]) + for row in self.connection.execute("SELECT * FROM snapshots") + } + self.effective = {**self.provisional, **self.snapshots} + self.checkpoint = _get(self.connection, "rollout", {}) + self.timeline = _get(self.connection, "attribution", {}) + + def _subagent_loader( + self, path: Path + ) -> tuple[dict[str, Any], list[dict[str, Any]]]: + key = f"subagent:{path}" + cursor = _get(self.connection, key, {}) + child_turns: list[dict[str, Any]] = [] + + def consume_child( + record: dict[str, Any], state: dict[str, Any], _reset: bool + ) -> None: + _, completed = parse_rollout( + [record], state=state["parser"], finalize=False + ) + child_turns.extend(completed) + + count, reset = _read_incremental(path, cursor, consume_child) + self.total_bytes += count + saved = [] if reset else cursor.get("turns", []) + saved.extend(child_turns) + cursor["turns"] = saved + child_session, active = parse_rollout([], state=cursor["parser"], finalize=True) + _put(self.connection, key, _private_cursor(cursor, self.config)) + return child_session, saved + active + + def _index_turn(self, turn: dict[str, Any], session: dict[str, Any]) -> None: + identifier = turn.get("turn_id") + if not isinstance(identifier, str) or not identifier: + return + row = self.connection.execute( + "SELECT finalized FROM captured_turns WHERE turn_id=?", (identifier,) + ).fetchone() + if self._already_final(row, identifier): + return + if identifier == self.turn_id or self._has_final_receipt(identifier): + turn["completed"] = True + self._write_captured_turn(row, identifier, turn, session) + + def _already_final(self, row: sqlite3.Row | None, identifier: str) -> bool: + if row is not None and row[0]: + return True + return row is not None and self._has_receipt(identifier) + + def _has_receipt(self, identifier: str) -> bool: + return bool( + self.connection.execute( + "SELECT 1 FROM receipts WHERE turn_id=?", (identifier,) + ).fetchone() + ) + + def _has_final_receipt(self, identifier: str) -> bool: + return bool( + self.connection.execute( + "SELECT 1 FROM receipts WHERE turn_id=? AND kind IN ('Stop','legacy')", + (identifier,), + ).fetchone() + ) + + def _write_captured_turn( + self, + row: sqlite3.Row | None, + identifier: str, + turn: dict[str, Any], + session: dict[str, Any], + ) -> None: + values = ( + json.dumps(_redact_state(turn, self.config)), + json.dumps(_redact_state(session, self.config)), + self.checkpoint.get("record_offset", self.checkpoint["offset"]), + identifier, + ) + query = ( + "INSERT INTO captured_turns (turn,session,source_end,turn_id) VALUES (?,?,?,?)" + if row is None + else "UPDATE captured_turns SET turn=?,session=?,source_end=? WHERE turn_id=?" + ) + self.connection.execute(query, values) + + def _materialize(self, identifier: str, trace: dict[str, Any]) -> None: + self._promote_snapshot(identifier) + if self.connection.execute( + "SELECT 1 FROM legacy WHERE turn_id=?", (identifier,) + ).fetchone(): + return + identity = hashlib.sha256( + f"{self.session_id}\0{identifier}".encode() + ).hexdigest() + count = observation_count(trace) + trace["metadata"]["darrow.delivery_id"] = identity + trace["metadata"]["darrow.expected_observation_count"] = count + document = json.dumps( + {"status": "pending", "traces": [trace]}, + sort_keys=True, + separators=(",", ":"), + ) + self.connection.execute( + "INSERT OR IGNORE INTO envelopes VALUES (?,?,?,?,?,?,?)", + (identifier, identity, document, count, "pending", None, os.getpid()), + ) + + def _promote_snapshot(self, identifier: str) -> None: + snapshot = self.effective.get(identifier) + if snapshot is None: + return + self.connection.execute( + "INSERT OR IGNORE INTO snapshots VALUES (?,?)", + (identifier, json.dumps(snapshot)), + ) + self.promoted.add(identifier) + + def _consume( + self, record: dict[str, Any], state: dict[str, Any], reset: bool + ) -> None: + if reset and not self.rebuilt: + self._reset_index() + self._record_turn_start(record) + session, completed = parse_rollout( + [record], state=state["parser"], finalize=False + ) + self._validate_parsed_session(session) + for turn in completed: + self._index_turn(turn, session) + + def _reset_index(self) -> None: + self.rebuilt = True + self.timeline.clear() + for table in ("indexed_turns", "turn_starts", "captured_turns"): + self.connection.execute(f"DELETE FROM {table}") + + def _record_turn_start(self, record: dict[str, Any]) -> None: + payload = record["payload"] + if record.get("type") != "event_msg" or payload.get("type") != "task_started": + return + identifier = payload.get("turn_id") + if not isinstance(identifier, str): + return + try: + self.connection.execute("INSERT INTO turn_starts VALUES (?)", (identifier,)) + except sqlite3.IntegrityError as error: + raise ValueError( + "hook turn_id does not identify exactly one rollout turn" + ) from error + + def _validate_parsed_session(self, session: dict[str, Any]) -> None: + allowed = {None, self.session_id, _redact_state(self.session_id, self.config)} + if session.get("session_id") not in allowed: + raise ValueError("rollout thread ID does not match the hook session") + + def _apply_terminal(self, event: dict[str, Any]) -> None: + query = "SELECT * FROM captured_turns WHERE finalized=0 AND source_end<=? ORDER BY rowid" + for pending in self.connection.execute(query, (event["offset"],)): + self._apply_terminal_row(event, pending) + + def _apply_terminal_row(self, event: dict[str, Any], pending: sqlite3.Row) -> None: + identifier = pending["turn_id"] + if event["event"] == "Interrupt" and identifier != event["turn_id"]: + return + if self._has_receipt(identifier): + return + self._store_terminal_snapshot(event, identifier) + self.connection.execute( + "INSERT INTO receipts VALUES (?,?,?)", + (identifier, event["event"], int(event["capture_content"])), + ) + turn = json.loads(pending["turn"]) + turn["completed"] = True + if event["event"] == "Interrupt": + turn["aborted"] = True + self.connection.execute( + "UPDATE captured_turns SET turn=? WHERE turn_id=?", + (json.dumps(turn), identifier), + ) + + def _store_terminal_snapshot(self, event: dict[str, Any], identifier: str) -> None: + snapshot = event["attribution_snapshots"].get(identifier) + if snapshot is None: + return + self.snapshots[identifier] = snapshot + self.connection.execute( + "INSERT OR IGNORE INTO snapshots VALUES (?,?)", + (identifier, json.dumps(snapshot)), + ) + + def _parse_boundaries(self) -> None: + boundaries = self._matching_boundaries() + for event in [*boundaries, None]: + limit = event["offset"] if event else None + count, did_reset = _read_incremental( + self.rollout, self.checkpoint, self._consume, max_offset=limit + ) + self.total_bytes += count + self.reset = self.reset or did_reset + self.session, active = parse_rollout( + [], state=self.checkpoint["parser"], finalize=True + ) + for turn in active: + self._index_turn(turn, self.session) + if event is not None: + self._apply_terminal(event) + + def _matching_boundaries(self) -> list[dict[str, Any]]: + identity = file_identity(self.rollout.stat()) + return sorted( + (event for event in self.terminal if event["identity"] == identity), + key=lambda item: (item["offset"], item["event"] != "Interrupt"), + ) + + def _validate_session(self) -> None: + if not self.session.get("session_id"): + raise ValueError("rollout is missing a valid Codex thread ID") + + def _record_stop(self) -> None: + row = self.connection.execute( + "SELECT turn,finalized FROM captured_turns WHERE turn_id=?", (self.turn_id,) + ).fetchone() + if self.turn_id is not None and row is None: + raise ValueError("hook turn_id does not identify exactly one rollout turn") + if self.turn_id is None or self._has_receipt(self.turn_id): + return + self._store_stop_snapshot(self.turn_id) + self.connection.execute( + "INSERT INTO receipts VALUES (?,?,?)", + (self.turn_id, "Stop", int(self.config.capture_content)), + ) + assert row is not None + if not row["finalized"]: + self._complete_stored_turn(row, self.turn_id) + + def _store_stop_snapshot(self, identifier: str) -> None: + if identifier not in self.snapshots: + self.snapshots[identifier] = attribution_snapshot(self.config, self.cwd) + self.connection.execute( + "INSERT INTO snapshots VALUES (?,?)", + (identifier, json.dumps(self.snapshots[identifier])), + ) + + def _complete_stored_turn(self, row: sqlite3.Row, identifier: str) -> None: + completed_turn = json.loads(row["turn"]) + completed_turn["completed"] = True + self.connection.execute( + "UPDATE captured_turns SET turn=? WHERE turn_id=?", + (json.dumps(completed_turn), identifier), + ) + + def _refresh_effective_snapshots(self) -> None: + self.effective = {**self.provisional, **self.snapshots} + query = "SELECT turn_id FROM receipts WHERE kind IN ('Interrupt','SessionEnd')" + for receipt in self.connection.execute(query): + if receipt[0] not in self.snapshots: + self.effective.pop(receipt[0], None) + + def _finalize_turns(self) -> None: + while True: + row = self.connection.execute( + "SELECT * FROM captured_turns WHERE finalized=0 ORDER BY rowid LIMIT 1" + ).fetchone() + if row is None or not self._ready_to_finalize(row): + return + self._finalize_row(row) + + def _ready_to_finalize(self, row: sqlite3.Row) -> bool: + identifier = row["turn_id"] + turn = json.loads(row["turn"]) + receipt = self._receipt(identifier) + uploaded = self.connection.execute( + "SELECT 1 FROM legacy WHERE turn_id=?", (identifier,) + ).fetchone() + has_evidence = receipt is not None or turn.get("aborted") or uploaded + return bool(has_evidence and turn.get("completed")) + + def _receipt(self, identifier: str) -> sqlite3.Row | None: + row = self.connection.execute( + "SELECT * FROM receipts WHERE turn_id=?", (identifier,) + ).fetchone() + return cast("sqlite3.Row | None", row) + + def _finalize_row(self, row: sqlite3.Row) -> None: + identifier = str(row["turn_id"]) + turn = json.loads(row["turn"]) + receipt = self._receipt(identifier) + turn_config = ( + replace(self.config, capture_content=bool(receipt["capture_content"])) + if receipt + else self.config + ) + document = trace_document( + self.rollout, + turn_config, + self.cwd, + attribution_snapshots=self.effective, + parsed=(json.loads(row["session"]), [turn]), + attribution_state=self.timeline, + subagent_loader=self._subagent_loader, + ) + trace = document["traces"][0] + self.connection.execute( + "INSERT OR REPLACE INTO indexed_turns VALUES (?,?)", + (identifier, json.dumps(trace)), + ) + self._materialize(identifier, trace) + self.connection.execute( + "UPDATE captured_turns SET finalized=1,turn=NULL WHERE turn_id=?", + (identifier,), + ) + + def _save_state(self) -> None: + _put(self.connection, "rollout", _private_cursor(self.checkpoint, self.config)) + _put(self.connection, "attribution", self.timeline) + _put(self.connection, "session_id", self.session_id) diff --git a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/cli.py b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/cli.py index 00e47ffd..5e97a129 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/cli.py +++ b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/cli.py @@ -6,11 +6,11 @@ from pathlib import Path from typing import Any +from .capture import capture, load_capture_snapshots from .config import Config, load_config from .context import delivery_context -from .export import export_document -from .capture import capture, load_capture_snapshots from .delivery import await_capture, drain +from .export import export_document from .lifecycle import record_terminal, registered_rollouts from .rollout import attribution_snapshot, trace_document from .sidecar import ( @@ -25,9 +25,11 @@ def _environment_true(name: str) -> bool: def _diagnose(message: str, *, config: Config | None = None) -> None: - if (config and (config.debug or config.strict)) or _environment_true( - "DARROW_LANGFUSE_DEBUG" - ) or _environment_true("DARROW_LANGFUSE_STRICT"): + if ( + (config and (config.debug or config.strict)) + or _environment_true("DARROW_LANGFUSE_DEBUG") + or _environment_true("DARROW_LANGFUSE_STRICT") + ): print(f"darrow-langfuse: {message}", file=sys.stderr) @@ -71,14 +73,18 @@ def _plugin_data() -> Path | None: def _completed_turn_ids(document: dict[str, Any]) -> set[str]: - result = set() + result: set[str] = set() traces = document.get("traces") if not isinstance(traces, list): return result for trace in traces: metadata = trace.get("metadata") if isinstance(trace, dict) else None turn_id = metadata.get("codex.turn_id") if isinstance(metadata, dict) else None - if metadata and metadata.get("codex.completed") is True and isinstance(turn_id, str): + if ( + metadata + and metadata.get("codex.completed") is True + and isinstance(turn_id, str) + ): result.add(turn_id) return result @@ -87,106 +93,197 @@ def run(*, background: bool = False) -> int: config: Config | None = None try: hook_input = _read_hook_input() - cwd = hook_input.get("cwd") if isinstance(hook_input.get("cwd"), str) else os.getcwd() + cwd_value = ( + hook_input.get("cwd") + if isinstance(hook_input.get("cwd"), str) + else os.getcwd() + ) + cwd = str(cwd_value) config = load_config(cwd) if not config.enabled: _diagnose("tracing is disabled", config=config) return 0 - hook_event_name = hook_input.get("hook_event_name") - session_id = _required_identifier(hook_input.get("session_id"), "session_id") - plugin_data = _plugin_data() + return _HookRunner(config, hook_input, cwd, _plugin_data()).execute(background) + except Exception as error: # The hook is fail-open unless strict mode is explicit. + _diagnose(str(error), config=config) + strict = ( + config.strict + if config is not None + else _environment_true("DARROW_LANGFUSE_STRICT") + ) + return 1 if strict else 0 + + +class _HookRunner: + def __init__( + self, + config: Config, + hook_input: dict[str, Any], + cwd: str, + plugin_data: Path | None, + ) -> None: + self.config = config + self.hook_input = hook_input + self.cwd = cwd + self.plugin_data = plugin_data + event = hook_input.get("hook_event_name") + self.event = event if isinstance(event, str) else "" + self.session_id = _required_identifier( + hook_input.get("session_id"), "session_id" + ) + + def execute(self, background: bool) -> int: if background: - if config.dry_run: - return 0 - transcript_path = hook_input.get("transcript_path") - if not config.public_key or not config.secret_key: - raise ValueError("Langfuse credentials are missing") - valid_path = isinstance(transcript_path, str) and Path(transcript_path).is_absolute() - if hook_event_name == "Stop": - if not valid_path: - raise ValueError("hook input is missing transcript_path") - if not await_capture(Path(transcript_path), _required_identifier(hook_input.get("turn_id"), "turn_id")): - raise ValueError("foreground capture has not completed") - drain(Path(transcript_path), config, cwd=cwd, exporter=export_document, plugin_data=plugin_data) - elif hook_event_name in {"SessionStart", "UserPromptSubmit"}: - targets = list(registered_rollouts(plugin_data, delivery_context(config, cwd))) - if valid_path: - targets.append((Path(transcript_path).resolve(), session_id)) - failure = None - for path, identifier in dict.fromkeys(targets): - try: - capture(path, config, cwd, identifier, None, plugin_data) - drain(path, config, cwd=cwd, exporter=export_document, plugin_data=plugin_data) - except Exception as error: - failure = error # One stale session cannot starve another backlog. - if failure is not None: - raise failure - else: - raise ValueError("hook input has an unsupported background event") + return self._background() + handlers = { + "Interrupt": self._terminal, + "SessionEnd": self._terminal, + "UserPromptSubmit": self._prompt, + "Stop": self._stop, + } + handler = handlers.get(self.event) + if handler is None: + raise ValueError("hook input has an unsupported hook_event_name") + return handler() + + def _background(self) -> int: + if self.config.dry_run: return 0 - if hook_event_name in {"Interrupt", "SessionEnd"}: - if config.dry_run: - return 0 - transcript_path = hook_input.get("transcript_path") - if not isinstance(transcript_path, str): - raise ValueError("hook input is missing transcript_path") - terminal_turn = _required_identifier(hook_input.get("turn_id"), "turn_id") if hook_event_name == "Interrupt" else None - record_terminal(Path(transcript_path), session_id, hook_event_name, terminal_turn, config, plugin_data, cwd=cwd) + self._require_credentials() + handlers = { + "Stop": self._background_stop, + "SessionStart": self._background_recover, + "UserPromptSubmit": self._background_recover, + } + handler = handlers.get(self.event) + if handler is None: + raise ValueError("hook input has an unsupported background event") + handler() + return 0 + + def _background_stop(self) -> None: + rollout = self._absolute_rollout() + turn_id = _required_identifier(self.hook_input.get("turn_id"), "turn_id") + if not await_capture(rollout, turn_id): + raise ValueError("foreground capture has not completed") + self._drain(rollout) + + def _background_recover(self) -> None: + targets = list( + registered_rollouts( + self.plugin_data, delivery_context(self.config, self.cwd) + ) + ) + transcript = self.hook_input.get("transcript_path") + if isinstance(transcript, str) and Path(transcript).is_absolute(): + targets.append((Path(transcript).resolve(), self.session_id)) + failure: Exception | None = None + for path, identifier in dict.fromkeys(targets): + try: + capture( + path, + self.config, + self.cwd, + identifier, + None, + self.plugin_data, + ) + self._drain(path) + except Exception as error: + failure = error + if failure is not None: + raise failure + + def _drain(self, rollout: Path) -> None: + drain( + rollout, + self.config, + cwd=self.cwd, + exporter=export_document, + plugin_data=self.plugin_data, + ) + + def _terminal(self) -> int: + if self.config.dry_run: return 0 - turn_id = _required_identifier(hook_input.get("turn_id"), "turn_id") - if hook_event_name == "UserPromptSubmit": - if config.dry_run: - return 0 - if plugin_data is None: - raise ValueError("PLUGIN_DATA is unavailable for provisional attribution") - record_provisional_attribution_snapshot( - plugin_data, - session_id, + terminal_turn = ( + _required_identifier(self.hook_input.get("turn_id"), "turn_id") + if self.event == "Interrupt" + else None + ) + record_terminal( + self._absolute_rollout(), + self.session_id, + str(self.event), + terminal_turn, + self.config, + self.plugin_data, + cwd=self.cwd, + ) + return 0 + + def _prompt(self) -> int: + if self.config.dry_run: + return 0 + if self.plugin_data is None: + raise ValueError("PLUGIN_DATA is unavailable for provisional attribution") + turn_id = _required_identifier(self.hook_input.get("turn_id"), "turn_id") + record_provisional_attribution_snapshot( + self.plugin_data, + self.session_id, + turn_id, + attribution_snapshot(self.config, self.cwd), + ) + return 0 + + def _stop(self) -> int: + rollout = self._absolute_rollout() + turn_id = _required_identifier(self.hook_input.get("turn_id"), "turn_id") + if not self.config.dry_run: + self._require_credentials() + capture( + rollout, + self.config, + self.cwd, + self.session_id, turn_id, - attribution_snapshot(config, cwd), + self.plugin_data, ) return 0 - if hook_event_name != "Stop": - raise ValueError("hook input has an unsupported hook_event_name") - transcript_path = hook_input.get("transcript_path") - if not isinstance(transcript_path, str) or not transcript_path: - raise ValueError("hook input is missing transcript_path") - rollout = Path(transcript_path) - if not rollout.is_absolute(): - raise ValueError("transcript_path must name a readable absolute file") - if not config.dry_run: - if not config.public_key or not config.secret_key: - raise ValueError("Langfuse credentials are missing") - capture(rollout, config, cwd, session_id, turn_id, plugin_data) - return 0 - snapshots = {**load_attribution_snapshots(rollout), **load_capture_snapshots(rollout)} + self._print_dry_run(rollout, turn_id) + return 0 + + def _print_dry_run(self, rollout: Path, turn_id: str) -> None: + snapshots = { + **load_attribution_snapshots(rollout), + **load_capture_snapshots(rollout), + } provisional = ( - load_provisional_attribution_snapshots(plugin_data, session_id) - if plugin_data is not None + load_provisional_attribution_snapshots(self.plugin_data, self.session_id) + if self.plugin_data is not None else {} ) - if turn_id in snapshots: - current_snapshot = snapshots[turn_id] - else: - current_snapshot = attribution_snapshot(config, cwd) - effective_snapshots = dict(provisional) - effective_snapshots.update(snapshots) - effective_snapshots[turn_id] = current_snapshot + current = snapshots.get(turn_id) or attribution_snapshot(self.config, self.cwd) + effective = {**provisional, **snapshots, turn_id: current} document = trace_document( - rollout, - config, - cwd, - attribution_snapshots=effective_snapshots, + rollout, self.config, self.cwd, attribution_snapshots=effective ) _complete_stop_turn(document, turn_id) - if config.dry_run: - json.dump(document, sys.stdout, separators=(",", ":"), sort_keys=True) - sys.stdout.write("\n") - return 0 - except Exception as error: # The hook is fail-open unless strict mode is explicit. - _diagnose(str(error), config=config) - strict = config.strict if config is not None else _environment_true("DARROW_LANGFUSE_STRICT") - return 1 if strict else 0 + json.dump(document, sys.stdout, separators=(",", ":"), sort_keys=True) + sys.stdout.write("\n") + + def _absolute_rollout(self) -> Path: + value = self.hook_input.get("transcript_path") + if not isinstance(value, str) or not value: + raise ValueError("hook input is missing transcript_path") + rollout = Path(value) + if not rollout.is_absolute(): + raise ValueError("transcript_path must name a readable absolute file") + return rollout + + def _require_credentials(self) -> None: + if not self.config.public_key or not self.config.secret_key: + raise ValueError("Langfuse credentials are missing") def main() -> None: diff --git a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/config.py b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/config.py index 0d0a83c3..d620ed8e 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/config.py +++ b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/config.py @@ -4,10 +4,10 @@ import os import re import subprocess +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path -from typing import Any, Mapping - +from typing import Any _WORK_ITEM = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") _LEADING_BRANCH_TOKEN = re.compile( @@ -49,7 +49,9 @@ def _read_config_file(path: Path) -> dict[str, Any]: try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as error: - raise ValueError(f"configuration file is unreadable or invalid: {path}") from error + raise ValueError( + f"configuration file is unreadable or invalid: {path}" + ) from error if not isinstance(value, dict): raise ValueError(f"configuration file must contain an object: {path}") return value @@ -99,13 +101,17 @@ def load_config( if variable in environment: merged[key] = environment[variable] - bool_fields = {name: _parse_bool(merged[name], name) for name in ( - "enabled", - "capture_content", - "dry_run", - "debug", - "strict", - ) if name in merged} + bool_fields = { + name: _parse_bool(merged[name], name) + for name in ( + "enabled", + "capture_content", + "dry_run", + "debug", + "strict", + ) + if name in merged + } max_chars = merged.get("max_chars", 20_000) try: max_chars = int(max_chars) diff --git a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/context.py b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/context.py index 2dd6c3de..ac582222 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/context.py +++ b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/context.py @@ -1,23 +1,39 @@ """Nonsecret, immutable authorization boundary for durable delivery work.""" + from __future__ import annotations import hashlib import json import os from pathlib import Path +from typing import Any + +from .config import Config -def delivery_context(config, cwd): - destination = json.dumps([config.base_url.rstrip("/"), config.public_key], separators=(",", ":")) +def delivery_context(config: Config, cwd: str) -> dict[str, Any]: + destination = json.dumps( + [config.base_url.rstrip("/"), config.public_key], separators=(",", ":") + ) return { "version": 1, "project": str(Path(cwd).resolve()), - "project_config": str((Path(cwd) / ".codex" / "darrow-langfuse.json").resolve()), - "user_config": str((Path(os.environ.get("HOME") or Path.home()) / ".codex" / "darrow-langfuse.json").resolve()), + "project_config": str( + (Path(cwd) / ".codex" / "darrow-langfuse.json").resolve() + ), + "user_config": str( + ( + Path(os.environ.get("HOME") or Path.home()) + / ".codex" + / "darrow-langfuse.json" + ).resolve() + ), "destination": hashlib.sha256(destination.encode()).hexdigest(), } -def require_context(saved, current): +def require_context(saved: Any, current: dict[str, Any]) -> None: if saved != current: - raise ValueError("Langfuse delivery context is missing or differs; preserve local work for reconciliation") + raise ValueError( + "Langfuse delivery context is missing or differs; preserve local work for reconciliation" + ) diff --git a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/delivery.py b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/delivery.py index 4cf858bf..328ec695 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/delivery.py +++ b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/delivery.py @@ -1,27 +1,37 @@ """One cancellable drainer per rollout; network never holds capture transactions.""" + from __future__ import annotations import fcntl +import hashlib import json import os -import time import sqlite3 -import hashlib +import time +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass from pathlib import Path +from typing import Any from .capture import database, database_path, observation_count -from .export import DeliveryFailure, export_document +from .config import Config from .context import delivery_context, require_context +from .export import DeliveryError, export_document -def await_capture(rollout: Path, turn_id: str, timeout: float = 45): +def await_capture(rollout: Path, turn_id: str, timeout: float = 45) -> bool: deadline = time.monotonic() + timeout while time.monotonic() < deadline: path = database_path(rollout) if path.exists(): - connection = sqlite3.connect(f"{path.as_uri()}?mode=ro", uri=True, timeout=1) + connection = sqlite3.connect( + f"{path.as_uri()}?mode=ro", uri=True, timeout=1 + ) try: - if connection.execute("SELECT 1 FROM receipts WHERE turn_id=?", (turn_id,)).fetchone(): + if connection.execute( + "SELECT 1 FROM receipts WHERE turn_id=?", (turn_id,) + ).fetchone(): return True except sqlite3.OperationalError: pass # Capture may still be creating the schema. @@ -31,72 +41,163 @@ def await_capture(rollout: Path, turn_id: str, timeout: float = 45): return False -def drain(rollout: Path, config, *, cwd, exporter=export_document, plugin_data=None) -> int: +def drain( + rollout: Path, + config: Config, + *, + cwd: str, + exporter: Callable[[dict[str, Any], Config], int] = export_document, + plugin_data: Path | None = None, +) -> int: rollout = rollout.resolve() if not database_path(rollout).exists(): return 0 - with database(rollout) as connection: - binding = connection.execute("SELECT value FROM state WHERE key='delivery_context'").fetchone() - require_context(json.loads(binding[0]) if binding else None, delivery_context(config, cwd)) - row = connection.execute("SELECT value FROM state WHERE key='session_id'").fetchone() - if row is None: - return 0 - session_id = json.loads(row[0]) + session_id = _session_id(rollout, config, cwd) + if session_id is None: + return 0 lock_directory = (plugin_data or rollout.parent) / "langfuse-drain-locks" lock_directory.mkdir(parents=True, exist_ok=True, mode=0o700) - lock_path = lock_directory / (hashlib.sha256(session_id.encode()).hexdigest() + ".lock") - descriptor = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600) + lock_path = ( + lock_directory / f"{hashlib.sha256(session_id.encode()).hexdigest()}.lock" + ) + with _exclusive_lock(lock_path) as acquired: + return _Drainer(rollout, config, exporter).run() if acquired else 0 + + +def _session_id(rollout: Path, config: Config, cwd: str) -> str | None: + with database(rollout) as connection: + binding = connection.execute( + "SELECT value FROM state WHERE key='delivery_context'" + ).fetchone() + require_context( + json.loads(binding[0]) if binding else None, delivery_context(config, cwd) + ) + row = connection.execute( + "SELECT value FROM state WHERE key='session_id'" + ).fetchone() + return str(json.loads(row[0])) if row is not None else None + + +@contextmanager +def _exclusive_lock(path: Path) -> Iterator[bool]: + descriptor = os.open(path, os.O_CREAT | os.O_RDWR, 0o600) try: try: fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: - return 0 - acknowledged = 0 - last_order = 0 - failure = None - while True: - with database(rollout) as connection: - connection.execute("BEGIN IMMEDIATE") - rows = [] - expected = 0 - for row in connection.execute("SELECT rowid AS queue_order,* FROM envelopes WHERE state='pending' AND rowid>? ORDER BY rowid LIMIT 512", (last_order,)): - if rows and expected + row["expected_count"] > 512: - break - rows.append(row) - expected += row["expected_count"] - if not rows: - connection.execute("COMMIT") - if failure is not None: - raise failure - return acknowledged - document = {"status": "pending", "traces": [json.loads(row["document"])["traces"][0] for row in rows]} - if any(observation_count(trace) != row["expected_count"] - or trace.get("metadata", {}).get("darrow.delivery_id") != row["identity"] - for trace, row in zip(document["traces"], rows)): - raise ValueError("Langfuse delivery envelope evidence is invalid") - last_order = rows[-1]["queue_order"] - identities = [(row["identity"],) for row in rows] - connection.executemany("UPDATE envelopes SET state='uncertain', reason='attempt started' WHERE identity=?", identities) - connection.execute("COMMIT") - try: - count = exporter(document, config) - if count != len(rows): - raise DeliveryFailure("uncertain", "Langfuse acceptance count mismatch") - except DeliveryFailure as error: - with database(rollout) as connection: - connection.execute("BEGIN IMMEDIATE") - connection.executemany("UPDATE envelopes SET state=?,reason=? WHERE identity=?", - [(error.outcome, str(error), row["identity"]) for row in rows]) - connection.execute("COMMIT") - failure = error - except BaseException: - # Includes cancellation: the durable pre-attempt state is uncertain. - raise - else: - with database(rollout) as connection: - connection.execute("BEGIN IMMEDIATE") - connection.executemany("UPDATE envelopes SET state='acknowledged',reason=NULL WHERE identity=?", identities) - connection.execute("COMMIT") - acknowledged += len(rows) + yield False + return + yield True finally: os.close(descriptor) + + +@dataclass(frozen=True) +class _Batch: + rows: list[sqlite3.Row] + document: dict[str, Any] + identities: list[tuple[str]] + last_order: int + + +class _Drainer: + def __init__( + self, + rollout: Path, + config: Config, + exporter: Callable[[dict[str, Any], Config], int], + ) -> None: + self.rollout = rollout + self.config = config + self.exporter = exporter + self.acknowledged = 0 + self.last_order = 0 + self.failure: DeliveryError | None = None + + def run(self) -> int: + while True: + batch = self._next_batch() + if batch is None: + if self.failure is not None: + raise self.failure + return self.acknowledged + self.last_order = batch.last_order + self._attempt(batch) + + def _next_batch(self) -> _Batch | None: + with database(self.rollout) as connection: + connection.execute("BEGIN IMMEDIATE") + rows = self._pending_rows(connection) + if not rows: + connection.execute("COMMIT") + return None + document = _batch_document(rows) + _validate_envelopes(document, rows) + identities = [(str(row["identity"]),) for row in rows] + connection.executemany( + "UPDATE envelopes SET state='uncertain', reason='attempt started' WHERE identity=?", + identities, + ) + connection.execute("COMMIT") + return _Batch(rows, document, identities, int(rows[-1]["queue_order"])) + + def _pending_rows(self, connection: sqlite3.Connection) -> list[sqlite3.Row]: + rows: list[sqlite3.Row] = [] + expected = 0 + query = "SELECT rowid AS queue_order,* FROM envelopes WHERE state='pending' AND rowid>? ORDER BY rowid LIMIT 512" + for row in connection.execute(query, (self.last_order,)): + if rows and expected + row["expected_count"] > 512: + break + rows.append(row) + expected += row["expected_count"] + return rows + + def _attempt(self, batch: _Batch) -> None: + try: + count = self.exporter(batch.document, self.config) + if count != len(batch.rows): + raise DeliveryError("uncertain", "Langfuse acceptance count mismatch") + except DeliveryError as error: + self._record_failure(batch, error) + self.failure = error + except BaseException: + raise # Cancellation retains the durable uncertain state. + else: + self._acknowledge(batch) + self.acknowledged += len(batch.rows) + + def _record_failure(self, batch: _Batch, error: DeliveryError) -> None: + values = [(error.outcome, str(error), row["identity"]) for row in batch.rows] + with database(self.rollout) as connection: + connection.execute("BEGIN IMMEDIATE") + connection.executemany( + "UPDATE envelopes SET state=?,reason=? WHERE identity=?", values + ) + connection.execute("COMMIT") + + def _acknowledge(self, batch: _Batch) -> None: + with database(self.rollout) as connection: + connection.execute("BEGIN IMMEDIATE") + connection.executemany( + "UPDATE envelopes SET state='acknowledged',reason=NULL WHERE identity=?", + batch.identities, + ) + connection.execute("COMMIT") + + +def _batch_document(rows: list[sqlite3.Row]) -> dict[str, Any]: + return { + "status": "pending", + "traces": [json.loads(row["document"])["traces"][0] for row in rows], + } + + +def _validate_envelopes(document: dict[str, Any], rows: list[sqlite3.Row]) -> None: + traces = document["traces"] + invalid = any( + observation_count(trace) != row["expected_count"] + or trace.get("metadata", {}).get("darrow.delivery_id") != row["identity"] + for trace, row in zip(traces, rows, strict=True) + ) + if invalid: + raise ValueError("Langfuse delivery envelope evidence is invalid") diff --git a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/export.py b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/export.py index 1624b21f..575caf4a 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/export.py +++ b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/export.py @@ -1,106 +1,211 @@ from __future__ import annotations import base64 +from collections.abc import Callable from importlib.metadata import version from typing import Any +from opentelemetry.sdk.trace.export import SpanExporter + from .config import Config _CLIENTS: dict[str, tuple[Any, Any, Config]] = {} -class DeliveryFailure(RuntimeError): - def __init__(self, outcome: str, message: str): + +class DeliveryError(RuntimeError): + def __init__(self, outcome: str, message: str) -> None: super().__init__(message) self.outcome = outcome class SingleAttemptExporter: """OTLP HTTP without retries after ambiguous acceptance.""" - def __init__(self, config: Config): + + def __init__(self, config: Config) -> None: self.config = config - self.failure = None + self.failure: DeliveryError | None = None self.accepted = 0 - def export(self, spans): - import requests - from opentelemetry.exporter.otlp.proto.common.trace_encoder import encode_spans - from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceResponse + def export(self, spans: Any) -> Any: from opentelemetry.sdk.trace.export import SpanExportResult - from urllib3.exceptions import NewConnectionError - config = self.config - credentials = base64.b64encode(f"{config.public_key}:{config.secret_key}".encode()).decode() try: - response = requests.post( - f"{config.base_url.rstrip('/')}/api/public/otel/v1/traces", - data=encode_spans(spans).SerializeToString(), - headers={"Authorization": f"Basic {credentials}", - "Content-Type": "application/x-protobuf", - "x-langfuse-sdk-name": "python", - "x-langfuse-sdk-version": version("langfuse"), - "x-langfuse-public-key": config.public_key or "", - "x-langfuse-ingestion-version": "4"}, - timeout=(5, 5), allow_redirects=False, - ) - if response.status_code != 200: - definite = response.status_code in {400, 401, 403, 404, 405, 413, 415, 422, 429} - raise DeliveryFailure("pending" if definite and not self.accepted else "uncertain", - f"Langfuse HTTP rejection ({response.status_code})") - if response.content: - result = ExportTraceServiceResponse() - if "json" in response.headers.get("Content-Type", "").lower(): - from google.protobuf.json_format import ParseDict - body = response.json() - # Langfuse v4's accepted queue response is a BullMQ job, - # while standard OTLP servers return ExportTraceServiceResponse. - queued = (isinstance(body, dict) - and body.get("name") == "otel-ingestion-job" - and isinstance(body.get("data"), dict) - and isinstance(body["data"].get("id"), str)) - if queued and ("partialSuccess" in body or "partial_success" in body): - ParseDict({key: value for key, value in body.items() - if key in {"partialSuccess", "partial_success"}}, result) - elif not queued: - ParseDict(body, result) - else: - result.ParseFromString(response.content) - if result.partial_success.rejected_spans or result.partial_success.error_message: - raise DeliveryFailure("uncertain", "Langfuse reported partial ingestion") + response = self._post(spans) + self._validate_response(response) self.accepted += len(spans) return SpanExportResult.SUCCESS - except DeliveryFailure as error: - self.failure = error - except (requests.exceptions.InvalidURL, requests.exceptions.InvalidSchema, - requests.exceptions.MissingSchema, requests.exceptions.InvalidHeader, - requests.exceptions.URLRequired, requests.exceptions.ConnectTimeout): - outcome = "pending" if not self.accepted else "uncertain" - self.failure = DeliveryFailure(outcome, "Langfuse request could not be started") - except requests.exceptions.ConnectionError as error: - def refused(value, seen=None): - seen = seen or set() - if id(value) in seen: - return False - seen.add(id(value)) - return isinstance(value, NewConnectionError) or any( - refused(child, seen) for child in ( - getattr(value, "reason", None), getattr(value, "__cause__", None), - *getattr(value, "args", ())) if isinstance(child, BaseException)) - outcome = "pending" if refused(error) and not self.accepted else "uncertain" - self.failure = DeliveryFailure(outcome, "Langfuse connection failed") - except Exception: - self.failure = DeliveryFailure("uncertain", "Langfuse response was not confirmed") + except Exception as error: + self.failure = self._classify_failure(error) return SpanExportResult.FAILURE - def force_flush(self, timeout_millis=30000): + def _post(self, spans: Any) -> Any: + import requests + from opentelemetry.exporter.otlp.proto.common.trace_encoder import encode_spans + + credentials = base64.b64encode( + f"{self.config.public_key}:{self.config.secret_key}".encode() + ).decode() + return requests.post( + f"{self.config.base_url.rstrip('/')}/api/public/otel/v1/traces", + data=encode_spans(spans).SerializeToString(), + headers={ + "Authorization": f"Basic {credentials}", + "Content-Type": "application/x-protobuf", + "x-langfuse-sdk-name": "python", + "x-langfuse-sdk-version": version("langfuse"), + "x-langfuse-public-key": self.config.public_key or "", + "x-langfuse-ingestion-version": "4", + }, + timeout=(5, 5), + allow_redirects=False, + ) + + def _validate_response(self, response: Any) -> None: + if response.status_code != 200: + self._reject_status(response.status_code) + if response.content: + self._validate_content(response) + + def _reject_status(self, status_code: int) -> None: + definite = status_code in {400, 401, 403, 404, 405, 413, 415, 422, 429} + outcome = "pending" if definite and not self.accepted else "uncertain" + raise DeliveryError(outcome, f"Langfuse HTTP rejection ({status_code})") + + @staticmethod + def _validate_content(response: Any) -> None: + from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ( + ExportTraceServiceResponse, + ) + + result = ExportTraceServiceResponse() + if "json" in response.headers.get("Content-Type", "").lower(): + _parse_json_response(response.json(), result) + else: + result.ParseFromString(response.content) + if ( + result.partial_success.rejected_spans + or result.partial_success.error_message + ): + raise DeliveryError("uncertain", "Langfuse reported partial ingestion") + + def _classify_failure(self, error: Exception) -> DeliveryError: + import requests + + if isinstance(error, DeliveryError): + return error + if isinstance(error, _pre_request_errors(requests)): + outcome = "pending" if not self.accepted else "uncertain" + return DeliveryError(outcome, "Langfuse request could not be started") + if isinstance(error, requests.exceptions.ConnectionError): + outcome = ( + "pending" + if _connection_refused(error, set()) and not self.accepted + else "uncertain" + ) + return DeliveryError(outcome, "Langfuse connection failed") + return DeliveryError("uncertain", "Langfuse response was not confirmed") + + def force_flush(self, timeout_millis: int = 30_000) -> bool: return True - def shutdown(self): - pass + def shutdown(self) -> None: + return None + + +def _parse_json_response(body: Any, result: Any) -> None: + from google.protobuf.json_format import ParseDict + + queued = _queued_response(body) + if queued and isinstance(body, dict): + partial = { + key: value + for key, value in body.items() + if key in {"partialSuccess", "partial_success"} + } + if partial: + ParseDict(partial, result) + elif not queued: + ParseDict(body, result) + + +def _queued_response(body: Any) -> bool: + return ( + isinstance(body, dict) + and body.get("name") == "otel-ingestion-job" + and isinstance(body.get("data"), dict) + and isinstance(body["data"].get("id"), str) + ) + + +def _pre_request_errors(requests: Any) -> tuple[type[Exception], ...]: + return ( + requests.exceptions.InvalidURL, + requests.exceptions.InvalidSchema, + requests.exceptions.MissingSchema, + requests.exceptions.InvalidHeader, + requests.exceptions.URLRequired, + requests.exceptions.ConnectTimeout, + ) + + +def _connection_refused(value: BaseException, seen: set[int]) -> bool: + from urllib3.exceptions import NewConnectionError + + if id(value) in seen: + return False + seen.add(id(value)) + children = ( + getattr(value, "reason", None), + getattr(value, "__cause__", None), + *getattr(value, "args", ()), + ) + return isinstance(value, NewConnectionError) or any( + _connection_refused(child, seen) + for child in children + if isinstance(child, BaseException) + ) + + +class _RecordingExporter(SpanExporter): + def __init__(self, delegate: SingleAttemptExporter) -> None: + self.delegate = delegate + self.results: list[Any] = [] + self.count = 0 + + def reset(self) -> None: + self.results.clear() + self.count = 0 + self.delegate.accepted = 0 + self.delegate.failure = None + + def export(self, spans: Any) -> Any: + from opentelemetry.sdk.trace.export import SpanExportResult + + if self.delegate.failure is not None: + return SpanExportResult.FAILURE + result = self.delegate.export(spans) + self.results.append(result) + if result is SpanExportResult.SUCCESS: + self.count += len(spans) + return result + def force_flush(self, timeout_millis: int = 30_000) -> bool: + return self.delegate.force_flush(timeout_millis=timeout_millis) -def _new_recording_client(config: Config) -> tuple[Any, Any]: + def shutdown(self) -> None: + self.delegate.shutdown() + + def failed(self) -> bool: + from opentelemetry.sdk.trace.export import SpanExportResult + + return any(result is not SpanExportResult.SUCCESS for result in self.results) + + def failure(self) -> DeliveryError | None: + return self.delegate.failure + + +def _new_recording_client(config: Config) -> tuple[Any, _RecordingExporter]: from langfuse import Langfuse - from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult key = config.public_key or "" if key in _CLIENTS: @@ -110,40 +215,7 @@ def _new_recording_client(config: Config) -> tuple[Any, Any]: exporter.reset() return client, exporter delegate = SingleAttemptExporter(config) - - class RecordingExporter(SpanExporter): - def __init__(self) -> None: - self.results: list[Any] = [] - self.count = 0 - - def reset(self): - self.results.clear() - self.count = 0 - delegate.accepted = 0 - delegate.failure = None - - def export(self, spans: Any) -> Any: - if delegate.failure is not None: - return SpanExportResult.FAILURE - result = delegate.export(spans) - self.results.append(result) - if result is SpanExportResult.SUCCESS: - self.count += len(spans) - return result - - def force_flush(self, timeout_millis: int = 30_000) -> bool: - return delegate.force_flush(timeout_millis=timeout_millis) - - def shutdown(self) -> None: - delegate.shutdown() - - def failed(self) -> bool: - return any(result is not SpanExportResult.SUCCESS for result in self.results) - - def failure(self): - return delegate.failure - - exporter = RecordingExporter() + exporter = _RecordingExporter(delegate) client = Langfuse( public_key=config.public_key, secret_key=config.secret_key, @@ -179,7 +251,9 @@ def _observation_attributes(value: dict[str, Any]) -> dict[str, Any]: return attributes -def _export_children(parent: Any, children: list[dict[str, Any]], ended) -> None: +def _export_children( + parent: Any, children: list[dict[str, Any]], ended: Callable[[], None] +) -> None: for child_value in children: child = parent.start_observation(**_observation_attributes(child_value)) _export_children(child, child_value.get("children") or [], ended) @@ -193,66 +267,88 @@ def export_document( *, client: Any | None = None, ) -> int: - from langfuse import propagate_attributes - - recording_exporter = None + recording_exporter: Any | None = None if client is None: client, recording_exporter = _new_recording_client(config) + traces = _validated_traces(document) + counter = _EndedCounter(client) + for trace in traces: + _export_trace(client, trace, counter) + client.flush() + _verify_recording(recording_exporter, counter.count) + return len(traces) + +def _validated_traces(document: dict[str, Any]) -> list[dict[str, Any]]: traces = document.get("traces") if not isinstance(traces, list): raise ValueError("trace document is missing traces") + if any(not isinstance(trace, dict) for trace in traces): + raise ValueError("trace document contains an invalid trace") + return traces - ended_count = 0 - def ended(): - nonlocal ended_count - ended_count += 1 - # Explicit flush bounds the SDK queue even for very large turn trees. - if ended_count % 512 == 0: - client.flush() +class _EndedCounter: + def __init__(self, client: Any) -> None: + self.client = client + self.count = 0 - for trace in traces: - if not isinstance(trace, dict): - raise ValueError("trace document contains an invalid trace") - root_value = { - **trace, - "type": "agent", - "metadata": { - **(trace.get("metadata") or {}), - "codex.session_id": trace.get("session_id"), - }, - } - attributes = _observation_attributes(root_value) - delivery_id = (trace.get("metadata") or {}).get("darrow.delivery_id") - if delivery_id: - attributes["trace_context"] = {"trace_id": delivery_id[:32]} - with client.start_as_current_observation(**attributes) as root: - metadata = trace.get("metadata") or {} - trace_metadata = { - key: metadata[key] - for key in ( - "codex.thread_id", - "darrow.attribution_epoch", - "darrow.attribution_source", - "darrow.work_item_id", - "darrow.delivery_id", - "darrow.expected_observation_count", - "git.branch", - "git.head", - ) - if metadata.get(key) is not None - } - with propagate_attributes( - session_id=trace.get("session_id"), - metadata=trace_metadata or None, - ): - _export_children(root, trace.get("observations") or [], ended) - ended() + def __call__(self) -> None: + self.count += 1 + if self.count % 512 == 0: + self.client.flush() - client.flush() - if recording_exporter is not None and recording_exporter.failed(): - raise recording_exporter.failure() or DeliveryFailure("uncertain", "Langfuse exporter rejected the trace batch") - if recording_exporter is not None and recording_exporter.count != ended_count: - raise DeliveryFailure("uncertain", "Langfuse observation count was not confirmed") - return len(traces) + +def _export_trace(client: Any, trace: dict[str, Any], ended: _EndedCounter) -> None: + from langfuse import propagate_attributes + + attributes = _observation_attributes(_root_value(trace)) + delivery_id = (trace.get("metadata") or {}).get("darrow.delivery_id") + if delivery_id: + attributes["trace_context"] = {"trace_id": delivery_id[:32]} + with ( + client.start_as_current_observation(**attributes) as root, + propagate_attributes( + session_id=trace.get("session_id"), + metadata=_trace_metadata(trace) or None, + ), + ): + _export_children(root, trace.get("observations") or [], ended) + ended() + + +def _root_value(trace: dict[str, Any]) -> dict[str, Any]: + return { + **trace, + "type": "agent", + "metadata": { + **(trace.get("metadata") or {}), + "codex.session_id": trace.get("session_id"), + }, + } + + +def _trace_metadata(trace: dict[str, Any]) -> dict[str, Any]: + metadata = trace.get("metadata") or {} + keys = ( + "codex.thread_id", + "darrow.attribution_epoch", + "darrow.attribution_source", + "darrow.work_item_id", + "darrow.delivery_id", + "darrow.expected_observation_count", + "git.branch", + "git.head", + ) + return {key: metadata[key] for key in keys if metadata.get(key) is not None} + + +def _verify_recording(recording_exporter: Any | None, ended_count: int) -> None: + if recording_exporter is None: + return + if recording_exporter.failed(): + raise recording_exporter.failure() or DeliveryError( + "uncertain", "Langfuse exporter rejected the trace batch" + ) + if recording_exporter.count != ended_count: + raise DeliveryError("uncertain", "Langfuse observation count was not confirmed") diff --git a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/lifecycle.py b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/lifecycle.py index bf4bc9ac..51442232 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/lifecycle.py +++ b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/lifecycle.py @@ -1,4 +1,5 @@ """Small immutable terminal receipts, independent of capture/database locks.""" + from __future__ import annotations import hashlib @@ -6,17 +7,21 @@ import os import sqlite3 import tempfile +from collections.abc import Iterator +from contextlib import suppress from pathlib import Path +from typing import Any -from .sidecar import _load_state_path, load_provisional_attribution_snapshots +from .config import Config from .context import delivery_context, require_context +from .sidecar import _load_state_path, load_provisional_attribution_snapshots -def file_identity(stat): +def file_identity(stat: os.stat_result) -> list[int]: return [stat.st_dev, stat.st_ino, getattr(stat, "st_birthtime_ns", 0)] -def _atomic_record(path, value): +def _atomic_record(path: Path, value: Any) -> None: path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) descriptor, temporary = tempfile.mkstemp(prefix=".receipt-", dir=path.parent) try: @@ -24,10 +29,8 @@ def _atomic_record(path, value): json.dump(value, handle, sort_keys=True, separators=(",", ":")) handle.flush() os.fsync(handle.fileno()) - try: + with suppress(FileExistsError): os.link(temporary, path) # First receipt wins; never replace evidence. - except FileExistsError: - pass directory = os.open(path.parent, os.O_RDONLY) try: os.fsync(directory) @@ -37,53 +40,104 @@ def _atomic_record(path, value): os.unlink(temporary) -def bind_rollout_context(rollout, session_id, context): +def bind_rollout_context( + rollout: Path, session_id: str, context: dict[str, Any] +) -> None: """First writer wins without waiting for a capture transaction.""" path = Path(f"{rollout}.darrow-langfuse-context") if not path.exists(): # Never assign a new origin to old durable evidence. Read-only SQLite # inspection is nonblocking; unreadable/locked legacy state refuses. - previous = Path(f"{rollout}.darrow-langfuse.sqlite3") - if previous.exists(): - connection = sqlite3.connect(f"{previous.as_uri()}?mode=ro", uri=True, timeout=0) - try: - binding = connection.execute("SELECT value FROM state WHERE key='delivery_context'").fetchone() - if binding: - require_context(json.loads(binding[0]), context) - elif any(connection.execute(f"SELECT 1 FROM {table} LIMIT 1").fetchone() - for table in ("state", "envelopes", "captured_turns", "receipts", "snapshots")): - require_context(None, context) - finally: - connection.close() - for event in terminal_records(rollout, session_id): - require_context(event.get("delivery_context"), context) + _validate_legacy_context(rollout, context) + _validate_terminal_contexts(rollout, session_id, context) _atomic_record(path, context) require_context(json.loads(path.read_text()), context) -def register_rollout(rollout, session_id, plugin_data, context): - if plugin_data is None: +def _validate_legacy_context(rollout: Path, context: dict[str, Any]) -> None: + previous = Path(f"{rollout}.darrow-langfuse.sqlite3") + if not previous.exists(): return - identity = hashlib.sha256(f"{rollout.resolve()}\0{session_id}".encode()).hexdigest() - _atomic_record(plugin_data / "langfuse-rollouts" / f"{identity}.json", - {"rollout": str(rollout.resolve()), "session_id": session_id, - "delivery_context": context}) + connection = sqlite3.connect(f"{previous.as_uri()}?mode=ro", uri=True, timeout=0) + try: + binding = connection.execute( + "SELECT value FROM state WHERE key='delivery_context'" + ).fetchone() + if binding: + require_context(json.loads(binding[0]), context) + elif _database_has_evidence(connection): + require_context(None, context) + finally: + connection.close() + +def _database_has_evidence(connection: sqlite3.Connection) -> bool: + tables = ("state", "envelopes", "captured_turns", "receipts", "snapshots") + return any( + connection.execute(f"SELECT 1 FROM {table} LIMIT 1").fetchone() + for table in tables + ) -def registered_rollouts(plugin_data, context): + +def _validate_terminal_contexts( + rollout: Path, session_id: str, context: dict[str, Any] +) -> None: + for event in terminal_records(rollout, session_id): + require_context(event.get("delivery_context"), context) + + +def register_rollout( + rollout: Path, + session_id: str, + plugin_data: Path | None, + context: dict[str, Any], +) -> None: + if plugin_data is None: + return + identity = hashlib.sha256(f"{rollout.resolve()}\0{session_id}".encode()).hexdigest() + _atomic_record( + plugin_data / "langfuse-rollouts" / f"{identity}.json", + { + "rollout": str(rollout.resolve()), + "session_id": session_id, + "delivery_context": context, + }, + ) + + +def registered_rollouts( + plugin_data: Path | None, context: dict[str, Any] +) -> Iterator[tuple[Path, str]]: if plugin_data is None: return for path in sorted((plugin_data / "langfuse-rollouts").glob("*.json")): value = json.loads(path.read_text()) - if (not isinstance(value, dict) or not isinstance(value.get("rollout"), str) - or not Path(value["rollout"]).is_absolute() - or not isinstance(value.get("session_id"), str) or not value["session_id"]): - raise ValueError("Langfuse rollout registry is invalid") + _validate_registry_entry(value) if value.get("delivery_context") == context: yield Path(value["rollout"]), value["session_id"] -def record_terminal(rollout, session_id, event, turn_id, config, plugin_data, *, cwd): +def _validate_registry_entry(value: Any) -> None: + if not isinstance(value, dict): + raise ValueError("Langfuse rollout registry is invalid") + rollout = value.get("rollout") + session_id = value.get("session_id") + if not isinstance(rollout, str) or not Path(rollout).is_absolute(): + raise ValueError("Langfuse rollout registry is invalid") + if not isinstance(session_id, str) or not session_id: + raise ValueError("Langfuse rollout registry is invalid") + + +def record_terminal( + rollout: Path, + session_id: str, + event: str, + turn_id: str | None, + config: Config, + plugin_data: Path | None, + *, + cwd: str, +) -> None: if event not in {"Interrupt", "SessionEnd"}: raise ValueError("invalid terminal hook") if not rollout.is_absolute() or not rollout.is_file(): @@ -92,26 +146,64 @@ def record_terminal(rollout, session_id, event, turn_id, config, plugin_data, *, stat = rollout.stat() context = delivery_context(config, cwd) bind_rollout_context(rollout, session_id, context) - value = {"version": 1, "uploaded_turn_ids": [], - "attribution_snapshots": load_provisional_attribution_snapshots(plugin_data, session_id) if plugin_data else {}, - "session_id": session_id, "event": event, "turn_id": turn_id, - "identity": file_identity(stat), "offset": stat.st_size, - "capture_content": config.capture_content, - "delivery_context": context} - identity = hashlib.sha256(json.dumps([session_id, event, turn_id, value["identity"], stat.st_size]).encode()).hexdigest() - register_rollout(rollout, session_id, plugin_data, value["delivery_context"]) - _atomic_record(Path(f"{rollout}.darrow-langfuse-events") / f"{identity}.json", value) - - -def terminal_records(rollout, session_id): + value = { + "version": 1, + "uploaded_turn_ids": [], + "attribution_snapshots": load_provisional_attribution_snapshots( + plugin_data, session_id + ) + if plugin_data + else {}, + "session_id": session_id, + "event": event, + "turn_id": turn_id, + "identity": file_identity(stat), + "offset": stat.st_size, + "capture_content": config.capture_content, + "delivery_context": context, + } + identity = hashlib.sha256( + json.dumps( + [session_id, event, turn_id, value["identity"], stat.st_size] + ).encode() + ).hexdigest() + register_rollout(rollout, session_id, plugin_data, context) + _atomic_record( + Path(f"{rollout}.darrow-langfuse-events") / f"{identity}.json", value + ) + + +def terminal_records(rollout: Path, session_id: str) -> Iterator[dict[str, Any]]: for path in sorted(Path(f"{rollout}.darrow-langfuse-events").glob("*.json")): _load_state_path(path) # Reuse validated snapshot format, not a second parser. value = json.loads(path.read_text()) - if (value.get("session_id") != session_id - or value.get("event") not in {"Interrupt", "SessionEnd"} - or not isinstance(value.get("identity"), list) or len(value["identity"]) != 3 - or type(value.get("offset")) is not int or value["offset"] < 0 - or type(value.get("capture_content")) is not bool - or (value["event"] == "Interrupt" and not isinstance(value.get("turn_id"), str))): - raise ValueError("Langfuse terminal receipt is invalid") + _validate_terminal_record(value, session_id) yield value + + +def _validate_terminal_record(value: Any, session_id: str) -> None: + if not isinstance(value, dict): + raise ValueError("Langfuse terminal receipt is invalid") + if value.get("session_id") != session_id: + raise ValueError("Langfuse terminal receipt is invalid") + if value.get("event") not in {"Interrupt", "SessionEnd"}: + raise ValueError("Langfuse terminal receipt is invalid") + if not _valid_terminal_position(value) or not _valid_terminal_event(value): + raise ValueError("Langfuse terminal receipt is invalid") + + +def _valid_terminal_position(value: dict[str, Any]) -> bool: + identity = value.get("identity") + offset = value.get("offset") + return ( + isinstance(identity, list) + and len(identity) == 3 + and type(offset) is int + and offset >= 0 + ) + + +def _valid_terminal_event(value: dict[str, Any]) -> bool: + if type(value.get("capture_content")) is not bool: + return False + return value["event"] != "Interrupt" or isinstance(value.get("turn_id"), str) diff --git a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/py.typed b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/rollout.py b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/rollout.py index ccb2d8a8..09ed16cd 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/rollout.py +++ b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/rollout.py @@ -1,8 +1,9 @@ from __future__ import annotations -import json import copy +import json import re +from collections.abc import Callable from pathlib import Path from typing import Any @@ -13,14 +14,13 @@ validate_work_item_id, ) - _SENSITIVE_KEY = re.compile( r"(?:authorization|api[-_]?key|secret|password|token|credential)", re.I ) _TOOL_CALL = re.compile(r"\btools\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(") _COMMAND_ARGUMENT = re.compile( - r'''(?:"(?:cmd|command)"|'(?:cmd|command)'|\b(?:cmd|command)\b)\s*:\s*''' - r'''(?P"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)''', + r"""(?:"(?:cmd|command)"|'(?:cmd|command)'|\b(?:cmd|command)\b)\s*:\s*""" + r"""(?P"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)""", re.S, ) _TOOL_NAME_MAX_CHARS = 512 @@ -29,12 +29,7 @@ def _clip(value: Any, limit: int, redactions: tuple[str, ...] = ()) -> Any: if isinstance(value, str): - for secret in redactions: - if secret: - value = value.replace(secret, "[REDACTED]") - if len(value) <= limit: - return value - return f"{value[:limit]}\n…[truncated {len(value) - limit} chars]" + return _clip_string(value, limit, redactions) if isinstance(value, list): return [_clip(item, limit, redactions) for item in value] if isinstance(value, dict): @@ -47,6 +42,14 @@ def _clip(value: Any, limit: int, redactions: tuple[str, ...] = ()) -> Any: return value +def _clip_string(value: str, limit: int, redactions: tuple[str, ...]) -> str: + for secret in filter(None, redactions): + value = value.replace(secret, "[REDACTED]") + if len(value) <= limit: + return value + return f"{value[:limit]}\n…[truncated {len(value) - limit} chars]" + + def _captured(value: Any, config: Config) -> Any: return _clip( value, @@ -97,13 +100,15 @@ def _reasoning_text(payload: dict[str, Any]) -> str | None: candidates = content if isinstance(content, list) else payload.get("summary") if not isinstance(candidates, list): return None - parts = [] - for item in candidates: - if isinstance(item, dict) and isinstance(item.get("text"), str): - parts.append(item["text"]) - elif isinstance(item, str): - parts.append(item) - return "\n".join(parts) or None + return "\n".join(filter(None, map(_reasoning_part, candidates))) or None + + +def _reasoning_part(item: Any) -> str | None: + if isinstance(item, str): + return item + if isinstance(item, dict) and isinstance(item.get("text"), str): + return str(item["text"]) + return None def _parse_arguments(raw: Any) -> Any: @@ -125,12 +130,9 @@ def _compact_source(value: str) -> str: def _decode_js_string(value: str) -> str: if value.startswith('"'): - try: - decoded = json.loads(value) - if isinstance(decoded, str): - return decoded - except json.JSONDecodeError: - pass + decoded = _decode_json_string(value) + if decoded is not None: + return decoded quote = value[0] body = value[1:-1] escapes = { @@ -143,7 +145,19 @@ def _decode_js_string(value: str) -> str: quote: quote, "\\": "\\", } - decoded = [] + return _decode_escaped_body(body, escapes) + + +def _decode_json_string(value: str) -> str | None: + try: + decoded = json.loads(value) + except json.JSONDecodeError: + return None + return decoded if isinstance(decoded, str) else None + + +def _decode_escaped_body(body: str, escapes: dict[str, str]) -> str: + decoded: list[str] = [] index = 0 while index < len(body): char = body[index] @@ -160,39 +174,51 @@ def _decode_js_string(value: str) -> str: def _call_arguments(source: str, start: int) -> str | None: depth = 1 quote: str | None = None - escaped = False for index in range(start, len(source)): - char = source[index] - if quote is not None: - if escaped: - escaped = False - elif char == "\\": - escaped = True - elif char == quote: - quote = None - continue - if char in {'"', "'", "`"}: - quote = char - elif char == "(": - depth += 1 - elif char == ")": - depth -= 1 - if depth == 0: - return source[start:index] + depth, quote = _advance_call_state(source, index, depth, quote) + if depth == 0: + return source[start:index] return None +def _advance_call_state( + source: str, index: int, depth: int, quote: str | None +) -> tuple[int, str | None]: + char = source[index] + if quote is not None: + return depth, _quote_after_character(source, index, quote) + if char in {'"', "'", "`"}: + return depth, char + if char == "(": + return depth + 1, quote + return (depth - 1, quote) if char == ")" else (depth, quote) + + +def _quote_after_character(source: str, index: int, quote: str) -> str | None: + if source[index] != quote: + return quote + escapes = 0 + cursor = index - 1 + while cursor >= 0 and source[cursor] == "\\": + escapes += 1 + cursor -= 1 + return quote if escapes % 2 else None + + def _short_tool_name(name: str) -> str: return name.rsplit("__", 1)[-1] def _command_from_arguments(arguments: Any) -> str | None: if isinstance(arguments, dict): - for key in ("cmd", "command"): - value = arguments.get(key) - if isinstance(value, str) and value: - return _single_line(value) - return None + return next( + ( + _single_line(value) + for key in ("cmd", "command") + if isinstance((value := arguments.get(key)), str) and value + ), + None, + ) if not isinstance(arguments, str): return None match = _COMMAND_ARGUMENT.search(arguments) @@ -253,11 +279,15 @@ def _attribution_directive(value: Any) -> tuple[str, str | None] | None: line = next((line.strip() for line in value.splitlines() if line.strip()), "") if not line.startswith(_ATTRIBUTION_DIRECTIVE): return None - parts = line.split() - if parts == [_ATTRIBUTION_DIRECTIVE, "clear"]: - return "clear", None - if parts == [_ATTRIBUTION_DIRECTIVE, "auto"]: - return "auto", None + return _parse_attribution_directive(line.split()) + + +def _parse_attribution_directive(parts: list[str]) -> tuple[str, str | None]: + if parts in ( + [_ATTRIBUTION_DIRECTIVE, "clear"], + [_ATTRIBUTION_DIRECTIVE, "auto"], + ): + return parts[1], None if len(parts) == 3 and parts[:2] == [_ATTRIBUTION_DIRECTIVE, "set"]: work_item_id = validate_work_item_id(parts[2]) if work_item_id is not None: @@ -267,6 +297,7 @@ def _attribution_directive(value: Any) -> tuple[str, str | None] | None: def attribution_snapshot(config: Config, cwd: str) -> dict[str, Any]: raw_branch, raw_head = git_provenance(cwd) + work_item_id: str | None if config.work_item_id: work_item_id = config.work_item_id source = "configuration" @@ -301,30 +332,37 @@ def _valid_usage(value: Any) -> dict[str, int] | None: "output_tokens": value["output_tokens"], "total_tokens": value["total_tokens"], } - cached = value.get("cached_input_tokens") - reasoning = value.get("reasoning_output_tokens") - if isinstance(cached, int) and 0 <= cached <= value["input_tokens"]: - details["cached_input_tokens"] = cached - if isinstance(reasoning, int) and 0 <= reasoning <= value["output_tokens"]: - details["reasoning_output_tokens"] = reasoning + _optional_usage(details, value, "cached_input_tokens", "input_tokens") + _optional_usage(details, value, "reasoning_output_tokens", "output_tokens") return details +def _optional_usage( + details: dict[str, int], value: dict[str, Any], key: str, limit_key: str +) -> None: + candidate = value.get(key) + if isinstance(candidate, int) and 0 <= candidate <= value[limit_key]: + details[key] = candidate + + def load_rollout(path: Path) -> list[dict[str, Any]]: if not path.is_absolute() or not path.is_file(): raise ValueError("transcript_path must name a readable absolute file") - records = [] try: - for raw in path.read_text(encoding="utf-8").splitlines(): - try: - value = json.loads(raw) - except json.JSONDecodeError: - continue - if isinstance(value, dict) and isinstance(value.get("payload"), dict): - records.append(value) + lines = path.read_text(encoding="utf-8").splitlines() except OSError as error: raise ValueError("transcript_path is not readable") from error - return records + return [record for raw in lines if (record := _parse_record(raw)) is not None] + + +def _parse_record(raw: str) -> dict[str, Any] | None: + try: + value = json.loads(raw) + except json.JSONDecodeError: + return None + if isinstance(value, dict) and isinstance(value.get("payload"), dict): + return value + return None def parse_rollout( @@ -333,20 +371,50 @@ def parse_rollout( state: dict[str, Any] | None = None, finalize: bool = True, ) -> tuple[dict[str, Any], list[dict[str, Any]]]: - session = (state or {}).get("session", {"session_id": None, "is_subagent": False}) - turns: list[dict[str, Any]] = [] - turn = (state or {}).get("turn") - step = (state or {}).get("step") - tools = { - tool["call_id"]: tool - for item in ((turn or {}).get("steps", []) + ([step] if step else [])) - for tool in item["tools"] - } + parser = _RolloutParser(state) + for record in records: + parser.consume(record) + parser.persist(state) + if finalize: + parser = parser.preview() if state is not None else parser + parser.close_turn( + parser.final_timestamp(records), completed=False, aborted=False + ) + return parser.session, parser.turns + + +class _RolloutParser: + def __init__(self, state: dict[str, Any] | None) -> None: + saved = state or {} + self.session: dict[str, Any] = saved.get( + "session", {"session_id": None, "is_subagent": False} + ) + self.turn: dict[str, Any] | None = saved.get("turn") + self.step: dict[str, Any] | None = saved.get("step") + self.turns: list[dict[str, Any]] = [] + active_steps = (self.turn or {}).get("steps", []) + self.tools = { + tool["call_id"]: tool + for item in active_steps + ([self.step] if self.step else []) + for tool in item["tools"] + } + + def preview(self) -> _RolloutParser: + duplicate = copy.copy(self) + duplicate.turn, duplicate.step = copy.deepcopy((self.turn, self.step)) + return duplicate + + def persist(self, state: dict[str, Any] | None) -> None: + if state is not None: + state.update(session=self.session, turn=self.turn, step=self.step) - def ensure_turn(timestamp: str) -> dict[str, Any]: - nonlocal turn - if turn is None: - turn = { + def final_timestamp(self, records: list[dict[str, Any]]) -> str: + source = records[-1] if records else self.turn or {} + return str(source.get("timestamp") or source.get("end_time") or "") + + def ensure_turn(self, timestamp: str) -> dict[str, Any]: + if self.turn is None: + self.turn = { "start_time": timestamp, "end_time": timestamp, "steps": [], @@ -354,147 +422,194 @@ def ensure_turn(timestamp: str) -> dict[str, Any]: "completed": False, "aborted": False, } - return turn + return self.turn - def ensure_step(timestamp: str) -> dict[str, Any]: - nonlocal step - current = ensure_turn(timestamp) - if step is None: - step = {"start_time": timestamp, "end_time": timestamp, "tools": []} + def ensure_step(self, timestamp: str) -> dict[str, Any]: + current = self.ensure_turn(timestamp) + if self.step is None: + self.step = {"start_time": timestamp, "end_time": timestamp, "tools": []} current["end_time"] = timestamp - return step + return self.step - def close_step(timestamp: str, usage: Any = None) -> None: - nonlocal step - if step is None: + def close_step(self, timestamp: str, usage: Any = None) -> None: + if self.step is None: return - step["end_time"] = timestamp + self.step["end_time"] = timestamp normalized = _valid_usage(usage) if normalized: - step["usage"] = normalized - assert turn is not None - turn["steps"].append(step) - step = None - - def close_turn(timestamp: str, *, completed: bool, aborted: bool) -> None: - nonlocal turn, tools - if turn is None: - return - close_step(timestamp) - turn["end_time"] = timestamp - turn["completed"] = completed - turn["aborted"] = aborted - if "output" not in turn and "last_agent_message" in turn: - turn["output"] = turn.pop("last_agent_message") - else: - turn.pop("last_agent_message", None) - turns.append(turn) - turn = None - tools = {} + self.step["usage"] = normalized + assert self.turn is not None + self.turn["steps"].append(self.step) + self.step = None - for record in records: + def close_turn(self, timestamp: str, *, completed: bool, aborted: bool) -> None: + if self.turn is None: + return + self.close_step(timestamp) + self.turn.update(end_time=timestamp, completed=completed, aborted=aborted) + self._promote_last_message() + self.turns.append(self.turn) + self.turn = None + self.tools = {} + + def _promote_last_message(self) -> None: + assert self.turn is not None + last_message = self.turn.pop("last_agent_message", None) + if "output" not in self.turn and last_message is not None: + self.turn["output"] = last_message + + def consume(self, record: dict[str, Any]) -> None: timestamp = str(record.get("timestamp") or "") kind = record.get("type") payload = record["payload"] - if kind == "session_meta": - if isinstance(payload.get("id"), str) and payload["id"].strip(): - session["session_id"] = payload["id"] - session["cli_version"] = payload.get("cli_version") - session["model_provider"] = payload.get("model_provider") - session["is_subagent"] = bool( - payload.get("parent_thread_id") or payload.get("thread_source") == "subagent" - ) - continue - if kind == "turn_context": - current = ensure_turn(timestamp) - current["model"] = payload.get("model") - current["invocation_parameters"] = payload - continue - if kind == "response_item": - current = ensure_turn(timestamp) - response_type = payload.get("type") - if response_type == "message": - text = _message_text(payload.get("content")) - if text and payload.get("role") == "assistant": - current_step = ensure_step(timestamp) - current_step["output"] = "\n".join( - filter(None, (current_step.get("output"), text)) - ) - elif text and payload.get("role") == "user" and "input" not in current: - current["input"] = text - elif response_type in {"function_call", "custom_tool_call"}: - call_id = str(payload.get("call_id") or f"call-{len(tools) + 1}") - tool = { - "call_id": call_id, - "name": str(payload.get("name") or "tool"), - "input": _parse_arguments(payload.get("arguments", payload.get("input"))), - "start_time": timestamp, - "end_time": timestamp, - } - ensure_step(timestamp)["tools"].append(tool) - tools[call_id] = tool - elif response_type in {"function_call_output", "custom_tool_call_output"}: - tool = tools.get(str(payload.get("call_id"))) - if tool is not None: - tool["output"] = payload.get("output") - tool["end_time"] = timestamp - elif response_type == "reasoning": - text = _reasoning_text(payload) - if text: - ensure_step(timestamp)["reasoning"] = text - continue - if kind != "event_msg": - continue + handlers = { + "session_meta": self._session_meta, + "turn_context": self._turn_context, + "response_item": self._response_item, + "event_msg": self._event, + } + handler = handlers.get(kind) if isinstance(kind, str) else None + if handler is not None: + handler(timestamp, payload) + + def _session_meta(self, _timestamp: str, payload: dict[str, Any]) -> None: + identifier = payload.get("id") + if isinstance(identifier, str) and identifier.strip(): + self.session["session_id"] = identifier + self.session["cli_version"] = payload.get("cli_version") + self.session["model_provider"] = payload.get("model_provider") + self.session["is_subagent"] = bool( + payload.get("parent_thread_id") + or payload.get("thread_source") == "subagent" + ) - event_type = payload.get("type") + def _turn_context(self, timestamp: str, payload: dict[str, Any]) -> None: + current = self.ensure_turn(timestamp) + current["model"] = payload.get("model") + current["invocation_parameters"] = payload + + def _response_item(self, timestamp: str, payload: dict[str, Any]) -> None: + response_type = payload.get("type") + handlers = { + "message": self._response_message, + "function_call": self._response_call, + "custom_tool_call": self._response_call, + "function_call_output": self._response_output, + "custom_tool_call_output": self._response_output, + "reasoning": self._response_reasoning, + } + self.ensure_turn(timestamp) + handler = ( + handlers.get(response_type) if isinstance(response_type, str) else None + ) + if handler is not None: + handler(timestamp, payload) + + def _response_message(self, timestamp: str, payload: dict[str, Any]) -> None: + text = _message_text(payload.get("content")) + role = payload.get("role") + if text and role == "assistant": + step = self.ensure_step(timestamp) + step["output"] = "\n".join(filter(None, (step.get("output"), text))) + elif text and role == "user" and self.turn is not None: + self.turn.setdefault("input", text) + + def _response_call(self, timestamp: str, payload: dict[str, Any]) -> None: + call_id = str(payload.get("call_id") or f"call-{len(self.tools) + 1}") + tool = { + "call_id": call_id, + "name": str(payload.get("name") or "tool"), + "input": _parse_arguments(payload.get("arguments", payload.get("input"))), + "start_time": timestamp, + "end_time": timestamp, + } + self.ensure_step(timestamp)["tools"].append(tool) + self.tools[call_id] = tool + + def _response_output(self, timestamp: str, payload: dict[str, Any]) -> None: + tool = self.tools.get(str(payload.get("call_id"))) + if tool is not None: + tool.update(output=payload.get("output"), end_time=timestamp) + + def _response_reasoning(self, timestamp: str, payload: dict[str, Any]) -> None: + text = _reasoning_text(payload) + if text: + self.ensure_step(timestamp)["reasoning"] = text + + def _event(self, timestamp: str, payload: dict[str, Any]) -> None: + event_type = str(payload.get("type") or "") if event_type == "task_started": - close_turn(timestamp, completed=False, aborted=False) - current = ensure_turn(timestamp) - current["turn_id"] = payload.get("turn_id") - continue - current = ensure_turn(timestamp) - if event_type == "user_message" and isinstance(payload.get("message"), str): - current.setdefault("input", payload["message"]) - elif event_type == "agent_message" and isinstance(payload.get("message"), str): - current["last_agent_message"] = payload["message"] - elif event_type == "token_count": - info = payload.get("info") if isinstance(payload.get("info"), dict) else {} - total = _valid_usage(info.get("total_token_usage")) - if total: - current["total_usage"] = total - close_step(timestamp, info.get("last_token_usage")) - elif event_type in {"collab_agent_spawn_end", "sub_agent_activity"}: - thread_id = payload.get("new_thread_id") or ( - payload.get("agent_thread_id") if payload.get("kind") == "started" else None - ) - if isinstance(thread_id, str) and thread_id not in current["subagent_thread_ids"]: - current["subagent_thread_ids"].append(thread_id) - elif isinstance(payload.get("call_id"), str) and event_type.endswith("_end"): - tool = tools.get(payload["call_id"]) - if tool is not None: - tool["end_time"] = timestamp - if payload.get("status") in {"failed", "declined"}: - tool["error"] = str( - payload.get("error") - or payload.get("aggregated_output") - or payload.get("stderr") - or "tool failed" - ) - if "output" not in tool and payload.get("aggregated_output") is not None: - tool["output"] = payload["aggregated_output"] + self.close_turn(timestamp, completed=False, aborted=False) + self.ensure_turn(timestamp)["turn_id"] = payload.get("turn_id") + return + handlers = { + "user_message": self._event_user_message, + "agent_message": self._event_agent_message, + "token_count": self._event_token_count, + "collab_agent_spawn_end": self._event_subagent, + "sub_agent_activity": self._event_subagent, + } + self.ensure_turn(timestamp) + handler = handlers.get(event_type, self._event_tool_end) + handler(timestamp, payload) + self._finish_event(timestamp, event_type) + + def _event_user_message(self, _timestamp: str, payload: dict[str, Any]) -> None: + message = payload.get("message") + if isinstance(message, str) and self.turn is not None: + self.turn.setdefault("input", message) + + def _event_agent_message(self, _timestamp: str, payload: dict[str, Any]) -> None: + message = payload.get("message") + if isinstance(message, str) and self.turn is not None: + self.turn["last_agent_message"] = message + + def _event_token_count(self, timestamp: str, payload: dict[str, Any]) -> None: + raw_info = payload.get("info") + info = raw_info if isinstance(raw_info, dict) else {} + total = _valid_usage(info.get("total_token_usage")) + if total and self.turn is not None: + self.turn["total_usage"] = total + self.close_step(timestamp, info.get("last_token_usage")) + + def _event_subagent(self, _timestamp: str, payload: dict[str, Any]) -> None: + thread_id = payload.get("new_thread_id") + if thread_id is None and payload.get("kind") == "started": + thread_id = payload.get("agent_thread_id") + assert self.turn is not None + children = self.turn["subagent_thread_ids"] + if isinstance(thread_id, str) and thread_id not in children: + children.append(thread_id) + + def _event_tool_end(self, timestamp: str, payload: dict[str, Any]) -> None: + call_id = payload.get("call_id") + event_type = str(payload.get("type") or "") + if not isinstance(call_id, str) or not event_type.endswith("_end"): + return + tool = self.tools.get(call_id) + if tool is None: + return + tool["end_time"] = timestamp + self._record_tool_error(tool, payload) + if "output" not in tool and payload.get("aggregated_output") is not None: + tool["output"] = payload["aggregated_output"] + + @staticmethod + def _record_tool_error(tool: dict[str, Any], payload: dict[str, Any]) -> None: + if payload.get("status") not in {"failed", "declined"}: + return + tool["error"] = str( + payload.get("error") + or payload.get("aggregated_output") + or payload.get("stderr") + or "tool failed" + ) + + def _finish_event(self, timestamp: str, event_type: str) -> None: if event_type == "task_complete": - close_turn(timestamp, completed=True, aborted=False) + self.close_turn(timestamp, completed=True, aborted=False) elif event_type == "turn_aborted": - close_turn(timestamp, completed=True, aborted=True) - - if state is not None: - state.update(session=session, turn=turn, step=step) - if finalize: - # The preview must not close the persisted active parser state. - if state is not None: - turn, step = copy.deepcopy((turn, step)) - close_turn(str(records[-1].get("timestamp") if records else (turn or {}).get("end_time", "")), completed=False, aborted=False) - return session, turns + self.close_turn(timestamp, completed=True, aborted=True) def _metadata( @@ -523,9 +638,7 @@ def _metadata( if attribution.get("head"): value["git.head"] = attribution["head"] return { - key: _redacted_metadata_string(item, config) - if isinstance(item, str) - else item + key: _redacted_metadata_string(item, config) if isinstance(item, str) else item for key, item in value.items() } @@ -548,85 +661,132 @@ def _turn_observations( config: Config, attribution: dict[str, Any], visited: set[Path], - subagent_loader: Any = None, + subagent_loader: Callable[[Path], tuple[dict[str, Any], list[dict[str, Any]]]] + | None = None, ) -> list[dict[str, Any]]: - observations = [] - for step in turn["steps"]: - generation: dict[str, Any] = { - "type": "generation", - "name": "LLM Subagent" if session["is_subagent"] else "LLM", - "model": turn.get("model"), - "start_time": step["start_time"], - "end_time": step["end_time"], - "usage_details": step.get("usage"), - "children": [], - } - if config.capture_content: - generation["output"] = _captured( - {"content": step.get("output"), "reasoning": step.get("reasoning")}, + observations = [ + _generation_observation(step, turn, session, config) for step in turn["steps"] + ] + for thread_id in turn["subagent_thread_ids"]: + observations.extend( + _subagent_observations( + path, + thread_id, config, + attribution, + visited, + subagent_loader, ) - for tool in step["tools"]: - error = tool.get("error") - child = { - "type": "tool", - "name": _tool_observation_name(tool, config), - "start_time": tool["start_time"], - "end_time": tool["end_time"], - "error": ( - _captured(error, config) - if error and config.capture_content - else "tool failed" if error else None - ), - } - if config.capture_content: - child["input"] = _captured(tool.get("input"), config) - child["output"] = _captured(tool.get("output"), config) - generation["children"].append(child) - observations.append(generation) - - for thread_id in turn["subagent_thread_ids"]: - child_path = _find_subagent_rollout(path, thread_id) - if child_path is None or child_path in visited: - continue - visited.add(child_path) - child_session, child_turns = ( - subagent_loader(child_path) if subagent_loader is not None - else parse_rollout(load_rollout(child_path)) ) - if child_session.get("session_id") != thread_id: - raise ValueError( - "subagent rollout thread ID does not match the spawned thread" - ) - for child_turn in child_turns: - child_observation: dict[str, Any] = { - "type": "agent", - "name": "Codex Subagent Turn", - "session_id": _redacted_metadata_string( - child_session["session_id"], config - ), - "start_time": child_turn["start_time"], - "end_time": child_turn["end_time"], - "metadata": _metadata( - child_turn, child_session, attribution, config - ), - "children": _turn_observations( - child_path, - child_turn, - child_session, - config, - attribution, - visited, - subagent_loader, - ), - } - if config.capture_content: - child_observation["input"] = _captured(child_turn.get("input"), config) - child_observation["output"] = _captured(child_turn.get("output"), config) - observations.append(child_observation) return observations +def _generation_observation( + step: dict[str, Any], + turn: dict[str, Any], + session: dict[str, Any], + config: Config, +) -> dict[str, Any]: + generation: dict[str, Any] = { + "type": "generation", + "name": "LLM Subagent" if session["is_subagent"] else "LLM", + "model": turn.get("model"), + "start_time": step["start_time"], + "end_time": step["end_time"], + "usage_details": step.get("usage"), + "children": [_tool_observation(tool, config) for tool in step["tools"]], + } + if config.capture_content: + generation["output"] = _captured( + {"content": step.get("output"), "reasoning": step.get("reasoning")}, + config, + ) + return generation + + +def _tool_observation(tool: dict[str, Any], config: Config) -> dict[str, Any]: + error = tool.get("error") + child = { + "type": "tool", + "name": _tool_observation_name(tool, config), + "start_time": tool["start_time"], + "end_time": tool["end_time"], + "error": _tool_error(error, config), + } + if config.capture_content: + child["input"] = _captured(tool.get("input"), config) + child["output"] = _captured(tool.get("output"), config) + return child + + +def _tool_error(error: Any, config: Config) -> Any: + if not error: + return None + return _captured(error, config) if config.capture_content else "tool failed" + + +def _subagent_observations( + path: Path, + thread_id: str, + config: Config, + attribution: dict[str, Any], + visited: set[Path], + loader: Callable[[Path], tuple[dict[str, Any], list[dict[str, Any]]]] | None, +) -> list[dict[str, Any]]: + child_path = _find_subagent_rollout(path, thread_id) + if child_path is None or child_path in visited: + return [] + visited.add(child_path) + child_session, child_turns = _load_subagent(child_path, loader) + if child_session.get("session_id") != thread_id: + raise ValueError("subagent rollout thread ID does not match the spawned thread") + return [ + _subagent_observation( + child_path, + turn, + child_session, + config, + attribution, + visited, + loader, + ) + for turn in child_turns + ] + + +def _load_subagent( + path: Path, + loader: Callable[[Path], tuple[dict[str, Any], list[dict[str, Any]]]] | None, +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + return loader(path) if loader is not None else parse_rollout(load_rollout(path)) + + +def _subagent_observation( + path: Path, + turn: dict[str, Any], + session: dict[str, Any], + config: Config, + attribution: dict[str, Any], + visited: set[Path], + loader: Callable[[Path], tuple[dict[str, Any], list[dict[str, Any]]]] | None, +) -> dict[str, Any]: + observation: dict[str, Any] = { + "type": "agent", + "name": "Codex Subagent Turn", + "session_id": _redacted_metadata_string(session["session_id"], config), + "start_time": turn["start_time"], + "end_time": turn["end_time"], + "metadata": _metadata(turn, session, attribution, config), + "children": _turn_observations( + path, turn, session, config, attribution, visited, loader + ), + } + if config.capture_content: + observation["input"] = _captured(turn.get("input"), config) + observation["output"] = _captured(turn.get("output"), config) + return observation + + def trace_document( path: Path, config: Config, @@ -635,7 +795,8 @@ def trace_document( attribution_snapshots: dict[str, dict[str, Any]] | None = None, parsed: tuple[dict[str, Any], list[dict[str, Any]]] | None = None, attribution_state: dict[str, Any] | None = None, - subagent_loader: Any = None, + subagent_loader: Callable[[Path], tuple[dict[str, Any], list[dict[str, Any]]]] + | None = None, ) -> dict[str, Any]: if not path.is_absolute(): raise ValueError("transcript_path must name a readable absolute file") @@ -645,82 +806,138 @@ def trace_document( raise ValueError("rollout is missing a valid Codex thread ID") thread_id = _redacted_metadata_string(session["session_id"], config) assert thread_id is not None - traces = [] visited = {path} automatic = ( - attribution_snapshot(config, cwd) - if attribution_snapshots is None - else None + attribution_snapshot(config, cwd) if attribution_snapshots is None else None ) timeline = attribution_state if attribution_state is not None else {} - mode = timeline.get("mode", "auto") - explicit_work_item_id = timeline.get("explicit_work_item_id") - epoch = timeline.get("epoch", 0) - previous_key = tuple(timeline["previous_key"]) if timeline.get("previous_key") else None - for turn in turns: + attribution = _AttributionTimeline( + config, + thread_id, + attribution_snapshots, + automatic, + timeline, + ) + traces = [ + _turn_trace( + path, + turn, + session, + config, + attribution.for_turn(turn), + visited, + subagent_loader, + ) + for turn in turns + ] + attribution.persist(timeline) + return {"status": "dry-run", "traces": traces} + + +class _AttributionTimeline: + def __init__( + self, + config: Config, + thread_id: str, + snapshots: dict[str, dict[str, Any]] | None, + automatic: dict[str, Any] | None, + state: dict[str, Any], + ) -> None: + self.config = config + self.thread_id = thread_id + self.snapshots = snapshots + self.automatic = automatic + self.mode = str(state.get("mode", "auto")) + self.explicit_work_item_id = state.get("explicit_work_item_id") + self.epoch = int(state.get("epoch", 0)) + previous = state.get("previous_key") + self.previous_key = tuple(previous) if previous else None + + def for_turn(self, turn: dict[str, Any]) -> dict[str, Any]: directive = _attribution_directive(turn.get("input")) if directive is not None: - mode, explicit_work_item_id = directive - epoch += 1 - turn_id = turn.get("turn_id") - missing_snapshot = False - if attribution_snapshots is None: - assert automatic is not None - fallback = automatic - elif isinstance(turn_id, str) and turn_id in attribution_snapshots: - fallback = attribution_snapshots[turn_id] - else: - missing_snapshot = True - fallback = { - "work_item_id": None, - "source": "none", - "branch": None, - "head": None, - } - if mode == "set": - attribution = { - "work_item_id": ( - None - if _contains_credential(explicit_work_item_id, config) - else explicit_work_item_id - ), - "source": "explicit", - } - elif mode == "clear": - attribution = {"work_item_id": None, "source": "explicit"} - else: - attribution = { - "work_item_id": fallback.get("work_item_id"), - "source": fallback["source"], - } + self.mode, self.explicit_work_item_id = directive + self.epoch += 1 + fallback, missing = self._fallback(turn.get("turn_id")) + attribution = self._base_attribution(fallback) attribution["branch"] = fallback.get("branch") attribution["head"] = fallback.get("head") - if mode == "auto" and missing_snapshot: - attribution["epoch"] = None - else: - effective_key = (attribution["source"], attribution.get("work_item_id")) - if ( - directive is None - and previous_key is not None - and effective_key != previous_key - ): - epoch += 1 - attribution["epoch"] = f"{thread_id}:attribution:{epoch}" - previous_key = effective_key - trace: dict[str, Any] = { - "name": "Codex Subagent Turn" if session["is_subagent"] else "Codex Turn", - "session_id": attribution["epoch"], - "start_time": turn["start_time"], - "end_time": turn["end_time"], - "metadata": _metadata(turn, session, attribution, config), - "observations": _turn_observations( - path, turn, session, config, attribution, visited, subagent_loader - ), + self._set_epoch(attribution, missing, directive) + return attribution + + def _fallback(self, turn_id: Any) -> tuple[dict[str, Any], bool]: + if self.snapshots is None: + assert self.automatic is not None + return self.automatic, False + if isinstance(turn_id, str) and turn_id in self.snapshots: + return self.snapshots[turn_id], False + return { + "work_item_id": None, + "source": "none", + "branch": None, + "head": None, + }, True + + def _base_attribution(self, fallback: dict[str, Any]) -> dict[str, Any]: + if self.mode == "set": + work_item_id = self.explicit_work_item_id + if _contains_credential(work_item_id, self.config): + work_item_id = None + return {"work_item_id": work_item_id, "source": "explicit"} + if self.mode == "clear": + return {"work_item_id": None, "source": "explicit"} + return { + "work_item_id": fallback.get("work_item_id"), + "source": fallback["source"], } - if config.capture_content: - trace["input"] = _captured(turn.get("input"), config) - trace["output"] = _captured(turn.get("output"), config) - traces.append(trace) - timeline.update(mode=mode, explicit_work_item_id=explicit_work_item_id, - epoch=epoch, previous_key=previous_key) - return {"status": "dry-run", "traces": traces} + + def _set_epoch( + self, + attribution: dict[str, Any], + missing_snapshot: bool, + directive: tuple[str, str | None] | None, + ) -> None: + if self.mode == "auto" and missing_snapshot: + attribution["epoch"] = None + return + effective_key = (attribution["source"], attribution.get("work_item_id")) + if directive is None and self._changed(effective_key): + self.epoch += 1 + attribution["epoch"] = f"{self.thread_id}:attribution:{self.epoch}" + self.previous_key = effective_key + + def _changed(self, effective_key: tuple[Any, Any]) -> bool: + return self.previous_key is not None and effective_key != self.previous_key + + def persist(self, state: dict[str, Any]) -> None: + state.update( + mode=self.mode, + explicit_work_item_id=self.explicit_work_item_id, + epoch=self.epoch, + previous_key=self.previous_key, + ) + + +def _turn_trace( + path: Path, + turn: dict[str, Any], + session: dict[str, Any], + config: Config, + attribution: dict[str, Any], + visited: set[Path], + loader: Callable[[Path], tuple[dict[str, Any], list[dict[str, Any]]]] | None, +) -> dict[str, Any]: + trace: dict[str, Any] = { + "name": "Codex Subagent Turn" if session["is_subagent"] else "Codex Turn", + "session_id": attribution["epoch"], + "start_time": turn["start_time"], + "end_time": turn["end_time"], + "metadata": _metadata(turn, session, attribution, config), + "observations": _turn_observations( + path, turn, session, config, attribution, visited, loader + ), + } + if config.capture_content: + trace["input"] = _captured(turn.get("input"), config) + trace["output"] = _captured(turn.get("output"), config) + return trace diff --git a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/sidecar.py b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/sidecar.py index 75ae766e..ae0d95c6 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/sidecar.py +++ b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/sidecar.py @@ -1,26 +1,31 @@ from __future__ import annotations +import fcntl import hashlib import json import os import re import tempfile -import fcntl +from collections.abc import Callable +from contextlib import suppress from functools import wraps from pathlib import Path -from typing import Any +from typing import Any, ParamSpec, TypeVar, cast from .config import validate_work_item_id - _CONTROL_CHARACTER = re.compile(r"[\x00-\x1f\x7f]") _GIT_HEAD = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", re.I) +P = ParamSpec("P") +R = TypeVar("R") -def _locked(path_for): - def decorate(function): +def _locked( + path_for: Callable[..., Path], +) -> Callable[[Callable[P, R]], Callable[P, R]]: + def decorate(function: Callable[P, R]) -> Callable[P, R]: @wraps(function) - def invoke(*args, **kwargs): + def invoke(*args: P.args, **kwargs: P.kwargs) -> R: path = path_for(*args, **kwargs) path.parent.mkdir(parents=True, exist_ok=True) descriptor = os.open(f"{path}.lock", os.O_CREAT | os.O_RDWR, 0o600) @@ -29,7 +34,9 @@ def invoke(*args, **kwargs): return function(*args, **kwargs) finally: os.close(descriptor) + return invoke + return decorate @@ -38,11 +45,7 @@ def sidecar_path(rollout: Path) -> Path: def _provisional_path(plugin_data: Path, session_id: str) -> Path: - if ( - not session_id - or len(session_id) > 256 - or _CONTROL_CHARACTER.search(session_id) - ): + if not session_id or len(session_id) > 256 or _CONTROL_CHARACTER.search(session_id): raise ValueError("Langfuse provisional attribution has an invalid session ID") digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest() return plugin_data / "attribution-snapshots" / f"{digest}.json" @@ -61,66 +64,80 @@ def _load_state_path(path: Path) -> dict[str, Any]: raise ValueError("Langfuse sidecar is unreadable or invalid") from error if not isinstance(value, dict): raise ValueError("Langfuse sidecar is unreadable or invalid") + _validate_version(value) + identifiers = _validated_identifiers(value.get("uploaded_turn_ids")) + snapshots = _validated_snapshots(value.get("attribution_snapshots", {})) + return { + "uploaded_turn_ids": identifiers, + "attribution_snapshots": snapshots, + } + + +def _validate_version(value: dict[str, Any]) -> None: version = value.get("version") - if version is None: - if "attribution_snapshots" in value: - raise ValueError("Langfuse sidecar attribution snapshots need version 1") - elif type(version) is not int or version != 1: + if version is None and "attribution_snapshots" in value: + raise ValueError("Langfuse sidecar attribution snapshots need version 1") + if version is not None and (type(version) is not int or version != 1): raise ValueError("Langfuse sidecar has an unsupported version") - identifiers = value.get("uploaded_turn_ids") - if not isinstance(identifiers, list) or any( - not isinstance(item, str) - or not item - or len(item) > 256 - or _CONTROL_CHARACTER.search(item) - for item in identifiers - ): + + +def _invalid_identifier(value: Any) -> bool: + return ( + not isinstance(value, str) + or not value + or len(value) > 256 + or _CONTROL_CHARACTER.search(value) is not None + ) + + +def _validated_identifiers(value: Any) -> list[str]: + if not isinstance(value, list) or any(_invalid_identifier(item) for item in value): raise ValueError("Langfuse sidecar has an invalid uploaded_turn_ids value") - snapshots = value.get("attribution_snapshots", {}) - if not isinstance(snapshots, dict): + return cast("list[str]", list(value)) + + +def _validated_snapshots(value: Any) -> dict[str, dict[str, Any]]: + if not isinstance(value, dict): raise ValueError("Langfuse sidecar has an invalid attribution_snapshots value") + snapshots: dict[Any, Any] = value for turn_id, snapshot in snapshots.items(): - if ( - not isinstance(turn_id, str) - or not turn_id - or len(turn_id) > 256 - or _CONTROL_CHARACTER.search(turn_id) - or not isinstance(snapshot, dict) - ): - raise ValueError("Langfuse sidecar has an invalid attribution snapshot") - source = snapshot.get("source") - if source not in {"configuration", "git_branch", "none"}: - raise ValueError("Langfuse sidecar has an invalid attribution snapshot") - work_item_id = snapshot.get("work_item_id") - try: - normalized_work_item_id = validate_work_item_id(work_item_id) - except ValueError as error: - raise ValueError( - "Langfuse sidecar has an invalid attribution snapshot" - ) from error - if (source == "none" and normalized_work_item_id is not None) or ( - source != "none" and normalized_work_item_id is None - ): - raise ValueError("Langfuse sidecar has an invalid attribution snapshot") - branch = snapshot.get("branch") - if branch is not None and ( - not isinstance(branch, str) - or not branch - or len(branch) > 1024 - or _CONTROL_CHARACTER.search(branch) - ): - raise ValueError("Langfuse sidecar has an invalid attribution snapshot") - if source == "git_branch" and branch is None: - raise ValueError("Langfuse sidecar has an invalid attribution snapshot") - head = snapshot.get("head") - if head is not None and ( - not isinstance(head, str) or _GIT_HEAD.fullmatch(head) is None - ): - raise ValueError("Langfuse sidecar has an invalid attribution snapshot") - return { - "uploaded_turn_ids": list(identifiers), - "attribution_snapshots": dict(snapshots), - } + _validate_snapshot(turn_id, snapshot) + return cast("dict[str, dict[str, Any]]", dict(snapshots)) + + +def _validate_snapshot(turn_id: Any, snapshot: Any) -> None: + if _invalid_identifier(turn_id) or not isinstance(snapshot, dict): + raise ValueError("Langfuse sidecar has an invalid attribution snapshot") + source = snapshot.get("source") + if source not in {"configuration", "git_branch", "none"}: + raise ValueError("Langfuse sidecar has an invalid attribution snapshot") + work_item_id = _validated_work_item(snapshot.get("work_item_id")) + if (source == "none") != (work_item_id is None): + raise ValueError("Langfuse sidecar has an invalid attribution snapshot") + _validate_provenance(source, snapshot.get("branch"), snapshot.get("head")) + + +def _validated_work_item(value: Any) -> str | None: + try: + return validate_work_item_id(value) + except ValueError as error: + raise ValueError( + "Langfuse sidecar has an invalid attribution snapshot" + ) from error + + +def _validate_provenance(source: Any, branch: Any, head: Any) -> None: + invalid_branch = branch is not None and ( + not isinstance(branch, str) + or not branch + or len(branch) > 1024 + or _CONTROL_CHARACTER.search(branch) is not None + ) + invalid_head = head is not None and ( + not isinstance(head, str) or _GIT_HEAD.fullmatch(head) is None + ) + if invalid_branch or invalid_head or (source == "git_branch" and branch is None): + raise ValueError("Langfuse sidecar has an invalid attribution snapshot") def _write_state(rollout: Path, state: dict[str, Any]) -> None: @@ -162,22 +179,23 @@ def _write_state_path(path: Path, state: dict[str, Any]) -> None: temporary = None finally: if temporary is not None: - try: + with suppress(OSError): os.unlink(temporary) - except OSError: - pass def load_attribution_snapshots(rollout: Path) -> dict[str, dict[str, Any]]: - return _load_state(rollout)["attribution_snapshots"] + return cast( + "dict[str, dict[str, Any]]", _load_state(rollout)["attribution_snapshots"] + ) def load_provisional_attribution_snapshots( plugin_data: Path, session_id: str ) -> dict[str, dict[str, Any]]: - return _load_state_path(_provisional_path(plugin_data, session_id))[ + snapshots = _load_state_path(_provisional_path(plugin_data, session_id))[ "attribution_snapshots" ] + return cast("dict[str, dict[str, Any]]", snapshots) @_locked(lambda rollout, *args, **kwargs: sidecar_path(rollout)) @@ -194,7 +212,11 @@ def record_attribution_snapshot( _write_state(rollout, state) -@_locked(lambda plugin_data, session_id, *args, **kwargs: _provisional_path(plugin_data, session_id)) +@_locked( + lambda plugin_data, session_id, *args, **kwargs: _provisional_path( + plugin_data, session_id + ) +) def record_provisional_attribution_snapshot( plugin_data: Path, session_id: str, @@ -212,7 +234,11 @@ def record_provisional_attribution_snapshot( _write_state_path(path, state) -@_locked(lambda plugin_data, session_id, *args, **kwargs: _provisional_path(plugin_data, session_id)) +@_locked( + lambda plugin_data, session_id, *args, **kwargs: _provisional_path( + plugin_data, session_id + ) +) def discard_provisional_attribution_snapshots( plugin_data: Path, session_id: str, turn_ids: set[str] ) -> None: @@ -237,7 +263,10 @@ def pending_document(document: dict[str, Any], rollout: Path) -> dict[str, Any]: pending = [] for trace in traces: metadata = trace.get("metadata") if isinstance(trace, dict) else None - if not isinstance(metadata, dict) or metadata.get("codex.completed") is not True: + if ( + not isinstance(metadata, dict) + or metadata.get("codex.completed") is not True + ): continue turn_id = metadata.get("codex.turn_id") if isinstance(turn_id, str) and turn_id in uploaded: @@ -253,14 +282,23 @@ def mark_exported_turns(rollout: Path, exported: dict[str, Any]) -> None: traces = exported.get("traces") if not isinstance(traces, list): raise ValueError("trace document is missing traces") + uploaded.update(_exported_turn_ids(traces)) + if not uploaded: + return + state["uploaded_turn_ids"] = sorted(uploaded) + _write_state(rollout, state) + + +def _exported_turn_ids(traces: list[Any]) -> set[str]: + identifiers: set[str] = set() for trace in traces: metadata = trace.get("metadata") if isinstance(trace, dict) else None - if not isinstance(metadata, dict) or metadata.get("codex.completed") is not True: + if ( + not isinstance(metadata, dict) + or metadata.get("codex.completed") is not True + ): continue turn_id = metadata.get("codex.turn_id") if isinstance(turn_id, str) and turn_id: - uploaded.add(turn_id) - if not uploaded: - return - state["uploaded_turn_ids"] = sorted(uploaded) - _write_state(rollout, state) + identifiers.add(turn_id) + return identifiers diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/benchmark_capture.py b/plugins/capability/darrow-observability-langfuse/backend/tests/benchmark_capture.py index 42ada103..4823ade7 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/tests/benchmark_capture.py +++ b/plugins/capability/darrow-observability-langfuse/backend/tests/benchmark_capture.py @@ -4,124 +4,292 @@ invocation is a fresh process, with generated append-only input and an in-process counting OTLP exporter (zero network delay and no remote ingestion claim). """ + from __future__ import annotations import argparse import hashlib +import importlib import json import resource import sys import tempfile import time +from collections.abc import Sequence from pathlib import Path +from typing import Any, cast + +from langfuse import Langfuse +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult + + +class _Counter(SpanExporter): + def __init__(self) -> None: + self.requests = 0 + self.observations = 0 + + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: + self.requests += 1 + self.observations += len(spans) + return SpanExportResult.SUCCESS + + def shutdown(self) -> None: + return None + + +def main() -> None: + args = _arguments() + sys.path.insert(0, args.source) + modules = { + name: importlib.import_module(f"darrow_observability_langfuse.{name}") + for name in ("capture", "config", "delivery", "export", "rollout", "sidecar") + } + result = _Benchmark(args.mode, args.turns, modules).run() + print(json.dumps(result)) -def main(): +def _arguments() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--source", required=True) parser.add_argument("--mode", choices=["control", "candidate"], required=True) parser.add_argument("--turns", type=int, required=True) - args = parser.parse_args() - sys.path.insert(0, args.source) - from langfuse import Langfuse - from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult - from darrow_observability_langfuse.config import Config - from darrow_observability_langfuse.export import export_document - from darrow_observability_langfuse import rollout as reconstruction - from darrow_observability_langfuse.sidecar import pending_document, mark_exported_turns, record_attribution_snapshot - - class Counter(SpanExporter): - requests = 0 - observations = 0 - def export(self, spans): - self.requests += 1 - self.observations += len(spans) - return SpanExportResult.SUCCESS - def shutdown(self): - pass - - config = Config(enabled=True, public_key="pk-benchmark", secret_key="sk-benchmark", work_item_id="TEST-77") - counter = Counter() - client = Langfuse(public_key=config.public_key, secret_key=config.secret_key, - flush_at=1 if args.mode == "control" else 512, - flush_interval=86400, span_exporter=counter) - snapshots = {} - bytes_read = 0 - foreground = 0 - outcome = 0 - original_load = reconstruction.load_rollout - def measured_load(path): - nonlocal bytes_read - bytes_read += path.stat().st_size - return original_load(path) - reconstruction.load_rollout = measured_load - if args.mode == "candidate": - from darrow_observability_langfuse.capture import capture, delivery_rows - from darrow_observability_langfuse.delivery import drain - def export(document, settings): - return export_document(document, settings, client=client) - def event(kind, **payload): - return {"timestamp": "2026-09-10T10:00:00Z", "type": "event_msg", "payload": {"type": kind, **payload}} - digest = hashlib.sha256() - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - path = root / "main.jsonl" - header = json.dumps({"type": "session_meta", "payload": {"id": "benchmark-session"}}) + "\n" + return parser.parse_args() + + +def _event(kind: str, **payload: Any) -> dict[str, Any]: + return { + "timestamp": "2026-09-10T10:00:00Z", + "type": "event_msg", + "payload": {"type": kind, **payload}, + } + + +class _Benchmark: + def __init__(self, mode: str, turns: int, modules: dict[str, Any]) -> None: + self.mode = mode + self.turns = turns + self.capture_module = modules["capture"] + self.delivery_module = modules["delivery"] + self.export_module = modules["export"] + self.reconstruction = modules["rollout"] + self.sidecar = modules["sidecar"] + self.config = modules["config"].Config( + enabled=True, + public_key="pk-benchmark", + secret_key="sk-benchmark", + work_item_id="TEST-77", + ) + self.counter = _Counter() + self.client = Langfuse( + public_key=self.config.public_key, + secret_key=self.config.secret_key, + flush_at=1 if mode == "control" else 512, + flush_interval=86400, + span_exporter=self.counter, + ) + self.snapshots: dict[str, dict[str, Any]] = {} + self.bytes_read = 0 + self.foreground_samples: list[float] = [] + self.input_bytes = 0 + self.outcome = 0 + self.digest = hashlib.sha256() + self._instrument_loads() + + def _instrument_loads(self) -> None: + original_load = self.reconstruction.load_rollout + + def measured_load(path: Path) -> list[dict[str, Any]]: + self.bytes_read += path.stat().st_size + return cast("list[dict[str, Any]]", original_load(path)) + + self.reconstruction.load_rollout = measured_load + + def run(self) -> dict[str, Any]: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / "main.jsonl" + self._write_header(path) + start = time.perf_counter() + for index in range(self.turns): + identifier = str(index) + self._append_turn(root, path, index) + self._measure(path, directory, identifier) + self._finish_candidate(path) + elapsed = time.perf_counter() - start + self._validate() + return self._result(elapsed) + + def _write_header(self, path: Path) -> None: + header = ( + json.dumps({"type": "session_meta", "payload": {"id": "benchmark-session"}}) + + "\n" + ) path.write_text(header) - digest.update(header.encode()) - start = time.perf_counter() - for index in range(args.turns): - identifier = str(index) - records = [event("task_started", turn_id=identifier), - {"type": "turn_context", "payload": {"model": "benchmark-model"}}, - event("user_message", message="A fixed prompt of bounded length.")] - for tool in range(2): - records.append({"type": "response_item", "payload": {"type": "function_call", "call_id": str(tool), "name": "exec_command", "arguments": '{"cmd":"true"}'}}) - records.append({"type": "response_item", "payload": {"type": "function_call_output", "call_id": str(tool), "output": "ok"}}) - if index % 100 == 0: - child = f"child-{index}" - child_records = [{"type": "session_meta", "payload": {"id": child, "parent_thread_id": "benchmark-session"}}, - event("task_started", turn_id=f"{child}-turn"), - {"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Child result"}]}}, - event("task_complete")] - content = "".join(json.dumps(row) + "\n" for row in child_records) - (root / f"rollout-{child}.jsonl").write_text(content) - digest.update(content.encode()) - records.append(event("collab_agent_spawn_end", new_thread_id=child)) - records.extend([event("token_count", info={"last_token_usage": {"input_tokens": 20, "output_tokens": 4, "total_tokens": 24}}), event("task_complete")]) - content = "".join(json.dumps(row) + "\n" for row in records) - digest.update(content.encode()) - with path.open("a") as handle: - handle.write(content) - tick = time.perf_counter() - if args.mode == "control": - snapshots[identifier] = reconstruction.attribution_snapshot(config, directory) - record_attribution_snapshot(path, identifier, snapshots[identifier]) - document = reconstruction.trace_document(path, config, directory, attribution_snapshots=snapshots) - document = pending_document(document, path) - outcome += export(document, config) - mark_exported_turns(path, document) - foreground += time.perf_counter() - tick - else: - metrics = capture(path, config, directory, "benchmark-session", identifier, None) - bytes_read += metrics["bytes_read"] - foreground += time.perf_counter() - tick - drain(path, config, cwd=directory, exporter=export) - if args.mode == "candidate": - outcome = sum(row["state"] == "acknowledged" for row in delivery_rows(path)) - elapsed = time.perf_counter() - start - peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss - if sys.platform != "darwin": - peak *= 1024 - expected_observations = 4 * args.turns + 2 * ((args.turns + 99) // 100) - assert outcome == args.turns, (outcome, args.turns) - assert counter.observations == expected_observations - assert counter.requests == (expected_observations if args.mode == "control" else args.turns) - print(json.dumps({"mode": args.mode, "turns": args.turns, "input_sha256": digest.hexdigest(), - "bytes_read": bytes_read, "exporter_requests": counter.requests, - "observations": counter.observations, "foreground_seconds": round(foreground, 4), - "total_seconds": round(elapsed, 4), "peak_rss_bytes": peak, - "acknowledged_turns": outcome})) + self.input_bytes += len(header.encode()) + self.digest.update(header.encode()) + + def _append_turn(self, root: Path, path: Path, index: int) -> None: + records = self._turn_records(index) + if index % 100 == 0: + child = f"child-{index}" + self._write_child(root, child) + records.append(_event("collab_agent_spawn_end", new_thread_id=child)) + records.extend(self._turn_end_records()) + content = "".join(json.dumps(row) + "\n" for row in records) + self.input_bytes += len(content.encode()) + self.digest.update(content.encode()) + with path.open("a") as handle: + handle.write(content) + + @staticmethod + def _turn_records(index: int) -> list[dict[str, Any]]: + records = [ + _event("task_started", turn_id=str(index)), + {"type": "turn_context", "payload": {"model": "benchmark-model"}}, + _event("user_message", message="A fixed prompt of bounded length."), + ] + for tool in range(2): + records.extend(_tool_records(tool)) + return records + + def _write_child(self, root: Path, child: str) -> None: + content = "".join(json.dumps(row) + "\n" for row in _child_records(child)) + (root / f"rollout-{child}.jsonl").write_text(content) + self.input_bytes += len(content.encode()) + self.digest.update(content.encode()) + + @staticmethod + def _turn_end_records() -> list[dict[str, Any]]: + return [ + _event( + "token_count", + info={ + "last_token_usage": { + "input_tokens": 20, + "output_tokens": 4, + "total_tokens": 24, + } + }, + ), + _event("task_complete"), + ] + + def _measure(self, path: Path, directory: str, identifier: str) -> None: + tick = time.perf_counter() + if self.mode == "control": + self._measure_control(path, directory, identifier) + else: + self._measure_candidate(path, directory, identifier) + self.foreground_samples.append(time.perf_counter() - tick) + + def _measure_control(self, path: Path, directory: str, identifier: str) -> None: + self.snapshots[identifier] = self.reconstruction.attribution_snapshot( + self.config, directory + ) + self.sidecar.record_attribution_snapshot( + path, identifier, self.snapshots[identifier] + ) + document = self.reconstruction.trace_document( + path, self.config, directory, attribution_snapshots=self.snapshots + ) + document = self.sidecar.pending_document(document, path) + self.outcome += self._export(document, self.config) + self.sidecar.mark_exported_turns(path, document) + + def _measure_candidate(self, path: Path, directory: str, identifier: str) -> None: + metrics = self.capture_module.capture( + path, self.config, directory, "benchmark-session", identifier, None + ) + self.bytes_read += metrics["bytes_read"] + self.delivery_module.drain( + path, self.config, cwd=directory, exporter=self._export + ) + + def _export(self, document: dict[str, Any], settings: Any) -> int: + return int( + self.export_module.export_document(document, settings, client=self.client) + ) + + def _finish_candidate(self, path: Path) -> None: + if self.mode == "candidate": + rows = self.capture_module.delivery_rows(path) + self.outcome = sum(row["state"] == "acknowledged" for row in rows) + + def _validate(self) -> None: + expected = 4 * self.turns + 2 * ((self.turns + 99) // 100) + assert self.outcome == self.turns, (self.outcome, self.turns) + assert self.counter.observations == expected + expected_requests = expected if self.mode == "control" else self.turns + assert self.counter.requests == expected_requests + if self.mode == "candidate": + assert self.bytes_read <= self.input_bytes * 2 + + def _result(self, elapsed: float) -> dict[str, Any]: + peak = float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) + if sys.platform != "darwin": + peak *= 1024 + startup = self.foreground_samples[0] if self.foreground_samples else 0.0 + steady = sum(self.foreground_samples[1:]) + steady_count = max(0, len(self.foreground_samples) - 1) + return { + "mode": self.mode, + "turns": self.turns, + "input_sha256": self.digest.hexdigest(), + "bytes_read": self.bytes_read, + "input_bytes": self.input_bytes, + "exporter_requests": self.counter.requests, + "observations": self.counter.observations, + "startup_foreground_seconds": round(startup, 4), + "steady_state_foreground_seconds": round(steady, 4), + "steady_state_seconds_per_turn": round( + steady / steady_count if steady_count else 0.0, 6 + ), + "total_seconds": round(elapsed, 4), + "peak_rss_bytes": int(peak), + "acknowledged_turns": self.outcome, + } + + +def _tool_records(tool: int) -> list[dict[str, Any]]: + return [ + { + "type": "response_item", + "payload": { + "type": "function_call", + "call_id": str(tool), + "name": "exec_command", + "arguments": '{"cmd":"true"}', + }, + }, + { + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": str(tool), + "output": "ok", + }, + }, + ] + + +def _child_records(child: str) -> list[dict[str, Any]]: + return [ + { + "type": "session_meta", + "payload": {"id": child, "parent_thread_id": "benchmark-session"}, + }, + _event("task_started", turn_id=f"{child}-turn"), + { + "type": "response_item", + "payload": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Child result"}], + }, + }, + _event("task_complete"), + ] if __name__ == "__main__": diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/smoke_ingestion.py b/plugins/capability/darrow-observability-langfuse/backend/tests/smoke_ingestion.py index 58280d5d..0eb6d2ab 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/tests/smoke_ingestion.py +++ b/plugins/capability/darrow-observability-langfuse/backend/tests/smoke_ingestion.py @@ -1,4 +1,5 @@ """Export a fixture to an explicitly configured isolated Langfuse project.""" + import json import os import tempfile @@ -10,23 +11,38 @@ from darrow_observability_langfuse.delivery import drain -def main(): - config = Config(enabled=True, strict=True, capture_content=False, - public_key=os.environ["LANGFUSE_PUBLIC_KEY"], - secret_key=os.environ["LANGFUSE_SECRET_KEY"], - base_url=os.environ["LANGFUSE_BASE_URL"], work_item_id="SMOKE-77") +def main() -> None: + config = Config( + enabled=True, + strict=True, + capture_content=False, + public_key=os.environ["LANGFUSE_PUBLIC_KEY"], + secret_key=os.environ["LANGFUSE_SECRET_KEY"], + base_url=os.environ["LANGFUSE_BASE_URL"], + work_item_id="SMOKE-77", + ) fixtures = Path(__file__).resolve().parents[2] / "tests" / "fixtures" session_id = "smoke-77-" + uuid.uuid4().hex with tempfile.TemporaryDirectory() as directory: for source in fixtures.glob("*.jsonl"): - (Path(directory) / source.name).write_text(source.read_text().replace("session-main", session_id)) + (Path(directory) / source.name).write_text( + source.read_text().replace("session-main", session_id) + ) path = Path(directory) / "main-rollout.jsonl" metrics = capture(path, config, directory, session_id, "turn-1", None) assert drain(path, config, cwd=directory) == 1 row = delivery_rows(path)[0] assert row["state"] == "acknowledged" - print(json.dumps({"state": row["state"], "trace_id": row["identity"][:32], - "expected_observations": row["expected_count"], **metrics})) + print( + json.dumps( + { + "state": row["state"], + "trace_id": row["identity"][:32], + "expected_observations": row["expected_count"], + **metrics, + } + ) + ) if __name__ == "__main__": diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/test_capture.py b/plugins/capability/darrow-observability-langfuse/backend/tests/test_capture.py index 81c874a7..577bbf31 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/tests/test_capture.py +++ b/plugins/capability/darrow-observability-langfuse/backend/tests/test_capture.py @@ -5,103 +5,172 @@ import unittest from pathlib import Path +from darrow_observability_langfuse.capture import capture, database, delivery_rows from darrow_observability_langfuse.config import Config -from darrow_observability_langfuse.capture import capture, delivery_rows, database class CaptureTest(unittest.TestCase): - def test_symlink_capture_and_delivery_share_canonical_state(self): - from test_delivery import rollout + def test_symlink_capture_and_delivery_share_canonical_state(self) -> None: from darrow_observability_langfuse.delivery import drain + from test_delivery import rollout + with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "rollout.jsonl" alias = Path(directory) / "alias.jsonl" rollout(path, 2) alias.symlink_to(path) - Path(f"{alias}.darrow-langfuse").write_text(json.dumps({"uploaded_turn_ids": ["0"]})) + Path(f"{alias}.darrow-langfuse").write_text( + json.dumps({"uploaded_turn_ids": ["0"]}) + ) config = Config(enabled=True) capture(alias, config, directory, "session", "1", None) self.assertEqual(len(delivery_rows(path)), 1) - self.assertEqual(drain(alias, config, cwd=directory, exporter=lambda doc, cfg: len(doc["traces"])), 1) + self.assertEqual( + drain( + alias, + config, + cwd=directory, + exporter=lambda doc, cfg: len(doc["traces"]), + ), + 1, + ) self.assertEqual(delivery_rows(path)[0]["state"], "acknowledged") - def test_duplicate_turn_id_is_refused_without_delivery(self): + def test_duplicate_turn_id_is_refused_without_delivery(self) -> None: from test_delivery import rollout + with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "rollout.jsonl" rollout(path, 2) - path.write_text(path.read_text().replace('"turn_id": "1"', '"turn_id": "0"')) + path.write_text( + path.read_text().replace('"turn_id": "1"', '"turn_id": "0"') + ) with self.assertRaisesRegex(ValueError, "exactly one"): capture(path, Config(enabled=True), directory, "session", "0", None) self.assertEqual(delivery_rows(path), []) - def test_active_parser_credentials_are_not_persisted(self): + def test_active_parser_credentials_are_not_persisted(self) -> None: with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "rollout.jsonl" - records = [{"type": "session_meta", "payload": {"id": "session"}}, - {"type": "event_msg", "payload": {"type": "task_started", "turn_id": "0"}}, - {"type": "event_msg", "payload": {"type": "user_message", "message": "private sk-super-secret content"}}] + records = [ + {"type": "session_meta", "payload": {"id": "session"}}, + { + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "0"}, + }, + { + "type": "event_msg", + "payload": { + "type": "user_message", + "message": "private sk-super-secret content", + }, + }, + ] path.write_text("".join(json.dumps(row) + "\n" for row in records)) config = Config(enabled=True, secret_key="sk-super-secret") capture(path, config, directory, "session", "0", None) with database(path) as connection: - stored = json.dumps([tuple(row) for row in connection.execute("SELECT * FROM state")]) + stored = json.dumps( + [tuple(row) for row in connection.execute("SELECT * FROM state")] + ) self.assertNotIn("sk-super-secret", stored) self.assertNotIn("private", delivery_rows(path)[0]["document"]) - def test_append_partial_line_version_rebuild_replacement_and_legacy_migration(self): + def test_append_partial_line_version_rebuild_replacement_and_legacy_migration( + self, + ) -> None: from test_delivery import rollout + with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "rollout.jsonl" config = Config(enabled=True) rollout(path) - Path(f"{path}.darrow-langfuse").write_text(json.dumps({"uploaded_turn_ids": ["0"]})) + Path(f"{path}.darrow-langfuse").write_text( + json.dumps({"uploaded_turn_ids": ["0"]}) + ) capture(path, config, directory, "session", "0", None) self.assertEqual(delivery_rows(path), []) - start = json.dumps({"type": "event_msg", "payload": {"type": "task_started", "turn_id": "1"}}).encode() + start = json.dumps( + { + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "1"}, + } + ).encode() with path.open("ab") as handle: handle.write(start[:20]) - self.assertEqual(capture(path, config, directory, "session", "0", None)["bytes_read"], 20) + self.assertEqual( + capture(path, config, directory, "session", "0", None)["bytes_read"], 20 + ) with path.open("ab") as handle: handle.write(start[20:] + b"\n") - self.assertEqual(capture(path, config, directory, "session", "1", None)["bytes_read"], len(start) - 19) + self.assertEqual( + capture(path, config, directory, "session", "1", None)["bytes_read"], + len(start) - 19, + ) original = delivery_rows(path)[0]["document"] with database(path) as connection: - row = json.loads(connection.execute("SELECT value FROM state WHERE key='rollout'").fetchone()[0]) + row = json.loads( + connection.execute( + "SELECT value FROM state WHERE key='rollout'" + ).fetchone()[0] + ) row["version"] = -1 - connection.execute("UPDATE state SET value=? WHERE key='rollout'", (json.dumps(row),)) - self.assertEqual(capture(path, config, directory, "session", "1", None)["rebuilt"], 1) + connection.execute( + "UPDATE state SET value=? WHERE key='rollout'", (json.dumps(row),) + ) + self.assertEqual( + capture(path, config, directory, "session", "1", None)["rebuilt"], 1 + ) self.assertEqual(delivery_rows(path)[0]["document"], original) replacement = path.with_suffix(".replacement") rollout(replacement, 2) replacement.replace(path) - self.assertEqual(capture(path, config, directory, "session", "1", None)["rebuilt"], 1) + self.assertEqual( + capture(path, config, directory, "session", "1", None)["rebuilt"], 1 + ) rollout(path) with self.assertRaisesRegex(ValueError, "turn_id"): capture(path, config, directory, "session", "1", None) - self.assertEqual(capture(path, config, directory, "session", "0", None)["rebuilt"], 1) + self.assertEqual( + capture(path, config, directory, "session", "0", None)["rebuilt"], 1 + ) self.assertEqual(delivery_rows(path)[0]["document"], original) - def test_invalid_capture_rolls_back_envelopes_and_cursor(self): - from test_delivery import rollout + def test_invalid_capture_rolls_back_envelopes_and_cursor(self) -> None: from unittest.mock import patch + + from test_delivery import rollout + with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "rollout.jsonl" rollout(path, 3) config = Config(enabled=True) - with patch("darrow_observability_langfuse.capture._put", side_effect=KeyboardInterrupt): - with self.assertRaises(KeyboardInterrupt): - capture(path, config, directory, "session", "2", None) + with ( + patch( + "darrow_observability_langfuse.capture._put", + side_effect=KeyboardInterrupt, + ), + self.assertRaises(KeyboardInterrupt), + ): + capture(path, config, directory, "session", "2", None) self.assertEqual(delivery_rows(path), []) - self.assertEqual(capture(path, config, directory, "session", "2", None)["bytes_read"], path.stat().st_size) + self.assertEqual( + capture(path, config, directory, "session", "2", None)["bytes_read"], + path.stat().st_size, + ) self.assertEqual(delivery_rows(path), []) capture(path, config, directory, "session", "0", None) capture(path, config, directory, "session", "1", None) self.assertEqual(len(delivery_rows(path)), 3) - def test_incremental_reconstruction_preserves_nested_observations(self): + def test_incremental_reconstruction_preserves_nested_observations(self) -> None: import shutil - from darrow_observability_langfuse.rollout import trace_document, attribution_snapshot + + from darrow_observability_langfuse.rollout import ( + attribution_snapshot, + trace_document, + ) + fixture = Path(__file__).resolve().parents[2] / "tests" / "fixtures" with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "main-rollout.jsonl" @@ -109,21 +178,31 @@ def test_incremental_reconstruction_preserves_nested_observations(self): shutil.copyfile(source, Path(directory) / source.name) config = Config(enabled=True, capture_content=True, work_item_id="TASK-77") snapshots = {"turn-1": attribution_snapshot(config, directory)} - expected = trace_document(path, config, directory, attribution_snapshots=snapshots)["traces"][0] + expected = trace_document( + path, config, directory, attribution_snapshots=snapshots + )["traces"][0] initial = capture(path, config, directory, "session-main", "turn-1", None) self.assertGreater(initial["bytes_read"], path.stat().st_size) actual = json.loads(delivery_rows(path)[0]["document"])["traces"][0] for key in ("darrow.delivery_id", "darrow.expected_observation_count"): actual["metadata"].pop(key) self.assertEqual(actual, expected) - self.assertEqual(capture(path, config, directory, "session-main", "turn-1", None)["bytes_read"], 0) + self.assertEqual( + capture(path, config, directory, "session-main", "turn-1", None)[ + "bytes_read" + ], + 0, + ) - def test_capture_is_local_incremental_and_immutable(self): + def test_capture_is_local_incremental_and_immutable(self) -> None: with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "rollout.jsonl" records = [ {"type": "session_meta", "payload": {"id": "session"}}, - {"type": "event_msg", "payload": {"type": "task_started", "turn_id": "one"}}, + { + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "one"}, + }, {"type": "event_msg", "payload": {"type": "task_complete"}}, ] path.write_text("".join(json.dumps(row) + "\n" for row in records)) diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/test_cli.py b/plugins/capability/darrow-observability-langfuse/backend/tests/test_cli.py index 2258af28..f7f5a92c 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/tests/test_cli.py +++ b/plugins/capability/darrow-observability-langfuse/backend/tests/test_cli.py @@ -5,19 +5,23 @@ import subprocess import tempfile import unittest +from collections.abc import Callable from io import StringIO from pathlib import Path +from typing import Any from unittest.mock import patch -from darrow_observability_langfuse.cli import run as hook_run from darrow_observability_langfuse.capture import database -from darrow_observability_langfuse.export import DeliveryFailure +from darrow_observability_langfuse.cli import run as hook_run from darrow_observability_langfuse.config import Config +from darrow_observability_langfuse.export import DeliveryError -def run(): +def run() -> int: """Exercise the two host invocations in order for compatibility assertions.""" import sys + + assert isinstance(sys.stdin, StringIO) payload = json.loads(sys.stdin.getvalue()) result = hook_run() if result == 0 and payload.get("hook_event_name") == "Stop": @@ -27,7 +31,7 @@ def run(): class CliTest(unittest.TestCase): - def test_exported_interrupted_snapshot_preserves_future_epoch_numbers(self): + def test_exported_interrupted_snapshot_preserves_future_epoch_numbers(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) rollout = root / "rollout.jsonl" @@ -40,7 +44,7 @@ def test_exported_interrupted_snapshot_preserves_future_epoch_numbers(self): } ] self._write_rollout(rollout, records) - exported: list[dict] = [] + exported: list[dict[str, Any]] = [] def invoke(event: str, turn_id: str, work_item_id: str) -> int: payload = { @@ -57,7 +61,7 @@ def invoke(event: str, turn_id: str, work_item_id: str) -> int: work_item_id=work_item_id, ) - def record_export(document: dict, _config: Config) -> int: + def record_export(document: dict[str, Any], _config: Config) -> int: exported.append(document) return len(document["traces"]) @@ -198,7 +202,7 @@ def record_export(document: dict, _config: Config) -> int: ], ) - def test_missing_interrupted_snapshot_is_exported_without_a_session(self): + def test_missing_interrupted_snapshot_is_exported_without_a_session(self) -> None: with tempfile.TemporaryDirectory() as directory: rollout = Path(directory) / "rollout.jsonl" records = [ @@ -224,7 +228,7 @@ def test_missing_interrupted_snapshot_is_exported_without_a_session(self): }, ] self._write_rollout(rollout, records) - exported: list[dict] = [] + exported: list[dict[str, Any]] = [] config = Config( enabled=True, public_key="pk-test", @@ -249,7 +253,10 @@ def test_missing_interrupted_snapshot_is_exported_without_a_session(self): { "timestamp": "2026-08-26T10:00:02.100Z", "type": "event_msg", - "payload": {"type": "user_message", "message": "Interrupted turn"}, + "payload": { + "type": "user_message", + "message": "Interrupted turn", + }, }, { "timestamp": "2026-08-26T10:00:02.200Z", @@ -269,7 +276,10 @@ def test_missing_interrupted_snapshot_is_exported_without_a_session(self): { "timestamp": "2026-08-26T10:00:03.200Z", "type": "event_msg", - "payload": {"type": "agent_message", "message": "Current response"}, + "payload": { + "type": "agent_message", + "message": "Current response", + }, }, ] ) @@ -281,14 +291,14 @@ def test_missing_interrupted_snapshot_is_exported_without_a_session(self): self.assertEqual(interrupted["metadata"]["codex.turn_id"], "turn-2") self.assertIsNone(interrupted["session_id"]) self.assertNotIn("darrow.attribution_epoch", interrupted["metadata"]) - self.assertEqual( - interrupted["metadata"]["darrow.attribution_source"], "none" - ) + self.assertEqual(interrupted["metadata"]["darrow.attribution_source"], "none") self.assertEqual(current["metadata"]["codex.turn_id"], "turn-3") self.assertEqual(current["session_id"], "session-main:attribution:0") self.assertEqual(current["metadata"]["darrow.work_item_id"], "TEST-1") - def test_interrupted_turn_uses_prompt_snapshot_without_splitting_epoch(self): + def test_interrupted_turn_uses_prompt_snapshot_without_splitting_epoch( + self, + ) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) repo = root / "repo" @@ -311,7 +321,7 @@ def test_interrupted_turn_uses_prompt_snapshot_without_splitting_epoch(self): } ] self._write_rollout(rollout, records) - exported: list[dict] = [] + exported: list[dict[str, Any]] = [] config = Config( enabled=True, public_key="pk-test", @@ -328,7 +338,7 @@ def invoke(event: str, turn_id: str) -> int: "prompt": "private prompt content", } - def record_export(document: dict, _config: Config) -> int: + def record_export(document: dict[str, Any], _config: Config) -> int: exported.append(document) return len(document["traces"]) @@ -370,7 +380,10 @@ def record_export(document: dict, _config: Config) -> int: { "timestamp": "2026-08-26T10:00:01.200Z", "type": "event_msg", - "payload": {"type": "agent_message", "message": "First response"}, + "payload": { + "type": "agent_message", + "message": "First response", + }, }, ] ) @@ -395,7 +408,10 @@ def record_export(document: dict, _config: Config) -> int: { "timestamp": "2026-08-26T10:00:02.100Z", "type": "event_msg", - "payload": {"type": "user_message", "message": "Interrupted turn"}, + "payload": { + "type": "user_message", + "message": "Interrupted turn", + }, }, { "timestamp": "2026-08-26T10:00:02.200Z", @@ -422,7 +438,10 @@ def record_export(document: dict, _config: Config) -> int: { "timestamp": "2026-08-26T10:00:03.200Z", "type": "event_msg", - "payload": {"type": "agent_message", "message": "Current response"}, + "payload": { + "type": "agent_message", + "message": "Current response", + }, }, ] ) @@ -450,7 +469,7 @@ def record_export(document: dict, _config: Config) -> int: {"session-main:attribution:0"}, ) - def test_same_session_directives_create_ordered_attribution_epochs(self): + def test_same_session_directives_create_ordered_attribution_epochs(self) -> None: with tempfile.TemporaryDirectory() as directory: rollout = Path(directory) / "rollout.jsonl" records = [ @@ -506,7 +525,7 @@ def test_same_session_directives_create_ordered_attribution_epochs(self): "".join(f"{json.dumps(record)}\n" for record in records), encoding="utf-8", ) - exported: list[dict] = [] + exported: list[dict[str, Any]] = [] for index in range(1, 7): self._run_stop( @@ -514,11 +533,11 @@ def test_same_session_directives_create_ordered_attribution_epochs(self): f"turn-{index}", exported, config=Config( - enabled=True, - public_key="pk-test", - secret_key="sk-test", - work_item_id="DEFAULT-1", - ), + enabled=True, + public_key="pk-test", + secret_key="sk-test", + work_item_id="DEFAULT-1", + ), ) traces = [trace for document in exported for trace in document["traces"]] @@ -557,7 +576,7 @@ def test_same_session_directives_create_ordered_attribution_epochs(self): {"session-main"}, ) - def test_export_retry_preserves_branch_fallback_and_git_provenance(self): + def test_export_retry_preserves_branch_fallback_and_git_provenance(self) -> None: with tempfile.TemporaryDirectory() as directory: repo = Path(directory) / "repo" repo.mkdir() @@ -603,8 +622,8 @@ def test_export_retry_preserves_branch_fallback_and_git_provenance(self): secret_key="sk-test", ) - def reject_export(_document: dict, _config: Config) -> int: - raise DeliveryFailure("pending", "temporary pre-acceptance failure") + def reject_export(_document: dict[str, Any], _config: Config) -> int: + raise DeliveryError("pending", "temporary pre-acceptance failure") self.assertEqual( self._invoke_stop(rollout, repo, "turn-1", config, reject_export), @@ -616,9 +635,9 @@ def reject_export(_document: dict, _config: Config) -> int: self._git(repo, "add", "issue-60.txt") self._git(repo, "commit", "-qm", "issue 60") second_head = self._git(repo, "rev-parse", "HEAD") - exported: list[dict] = [] + exported: list[dict[str, Any]] = [] - def record_export(document: dict, _config: Config) -> int: + def record_export(document: dict[str, Any], _config: Config) -> int: exported.append(document) return len(document["traces"]) @@ -628,7 +647,9 @@ def record_export(document: dict, _config: Config) -> int: ) first = exported.pop()["traces"][0] self.assertEqual(first["metadata"]["darrow.work_item_id"], "issue-45") - self.assertEqual(first["metadata"]["darrow.attribution_source"], "git_branch") + self.assertEqual( + first["metadata"]["darrow.attribution_source"], "git_branch" + ) self.assertEqual(first["metadata"]["git.branch"], "feat/issue-45-first") self.assertEqual(first["metadata"]["git.head"], first_head) self.assertEqual(first["session_id"], "session-main:attribution:0") @@ -665,7 +686,9 @@ def record_export(document: dict, _config: Config) -> int: self.assertEqual(second["metadata"]["git.head"], second_head) self.assertEqual(second["session_id"], "session-main:attribution:1") - def test_explicit_directive_attributes_a_turn_on_main_before_a_ticket_branch(self): + def test_explicit_directive_attributes_a_turn_on_main_before_a_ticket_branch( + self, + ) -> None: with tempfile.TemporaryDirectory() as directory: repo = Path(directory) self._git(repo, "init", "-q") @@ -708,7 +731,7 @@ def test_explicit_directive_attributes_a_turn_on_main_before_a_ticket_branch(sel }, ], ) - exported: list[dict] = [] + exported: list[dict[str, Any]] = [] self._run_stop( rollout, @@ -727,7 +750,9 @@ def test_explicit_directive_attributes_a_turn_on_main_before_a_ticket_branch(sel self.assertEqual(trace["metadata"]["git.branch"], "main") self.assertEqual(trace["metadata"]["git.head"], head) - def test_missing_codex_thread_id_refuses_before_export_or_sidecar_write(self): + def test_missing_codex_thread_id_refuses_before_export_or_sidecar_write( + self, + ) -> None: with tempfile.TemporaryDirectory() as directory: rollout = Path(directory) / "rollout.jsonl" self._write_rollout( @@ -750,9 +775,9 @@ def test_missing_codex_thread_id_refuses_before_export_or_sidecar_write(self): }, ], ) - exported: list[dict] = [] + exported: list[dict[str, Any]] = [] - def record_export(document: dict, _config: Config) -> int: + def record_export(document: dict[str, Any], _config: Config) -> int: exported.append(document) return len(document["traces"]) @@ -773,7 +798,9 @@ def record_export(document: dict, _config: Config) -> int: self.assertEqual(exported, []) self.assertFalse(Path(f"{rollout}.darrow-langfuse").exists()) - def test_missing_subagent_thread_id_refuses_before_export_or_sidecar_write(self): + def test_missing_subagent_thread_id_refuses_before_export_or_sidecar_write( + self, + ) -> None: with tempfile.TemporaryDirectory() as directory: rollout = Path(directory) / "rollout.jsonl" self._write_rollout( @@ -827,9 +854,9 @@ def test_missing_subagent_thread_id_refuses_before_export_or_sidecar_write(self) }, ], ) - exported: list[dict] = [] + exported: list[dict[str, Any]] = [] - def record_export(document: dict, _config: Config) -> int: + def record_export(document: dict[str, Any], _config: Config) -> int: exported.append(document) return len(document["traces"]) @@ -850,7 +877,9 @@ def record_export(document: dict, _config: Config) -> int: self.assertEqual(exported, []) self.assertFalse(Path(f"{rollout}.darrow-langfuse").exists()) - def test_credentials_are_redacted_from_attribution_metadata_and_sidecar(self): + def test_credentials_are_redacted_from_attribution_metadata_and_sidecar( + self, + ) -> None: with tempfile.TemporaryDirectory() as directory: repo = Path(directory) self._git(repo, "init", "-q") @@ -881,9 +910,9 @@ def test_credentials_are_redacted_from_attribution_metadata_and_sidecar(self): }, ], ) - exported: list[dict] = [] + exported: list[dict[str, Any]] = [] - def record_export(document: dict, _config: Config) -> int: + def record_export(document: dict[str, Any], _config: Config) -> int: exported.append(document) return len(document["traces"]) @@ -904,13 +933,23 @@ def record_export(document: dict, _config: Config) -> int: self.assertNotIn("issue-60", json.dumps(exported)) with database(rollout) as connection: - self.assertNotIn("issue-60", json.dumps([tuple(row) for row in connection.execute("SELECT * FROM snapshots")])) + self.assertNotIn( + "issue-60", + json.dumps( + [ + tuple(row) + for row in connection.execute("SELECT * FROM snapshots") + ] + ), + ) trace = exported[0]["traces"][0] self.assertNotIn("darrow.work_item_id", trace["metadata"]) self.assertEqual(trace["metadata"]["darrow.attribution_source"], "none") self.assertEqual(trace["metadata"]["git.branch"], "feat/[REDACTED]-secret") - def test_repeated_stop_exports_each_turn_once_before_task_complete_is_written(self): + def test_repeated_stop_exports_each_turn_once_before_task_complete_is_written( + self, + ) -> None: with tempfile.TemporaryDirectory() as directory: rollout = Path(directory) / "rollout.jsonl" records = [ @@ -948,7 +987,7 @@ def test_repeated_stop_exports_each_turn_once_before_task_complete_is_written(se "".join(f"{json.dumps(record)}\n" for record in records), encoding="utf-8", ) - exported: list[dict] = [] + exported: list[dict[str, Any]] = [] self._run_stop(rollout, "turn-1", exported) @@ -1002,7 +1041,9 @@ def test_repeated_stop_exports_each_turn_once_before_task_complete_is_written(se [["turn-1"], ["turn-2"]], ) - def test_stop_keeps_unrelated_incomplete_turn_and_dependent_current_local(self): + def test_stop_keeps_unrelated_incomplete_turn_and_dependent_current_local( + self, + ) -> None: with tempfile.TemporaryDirectory() as directory: rollout = Path(directory) / "rollout.jsonl" records = [ @@ -1044,7 +1085,7 @@ def test_stop_keeps_unrelated_incomplete_turn_and_dependent_current_local(self): "".join(f"{json.dumps(record)}\n" for record in records), encoding="utf-8", ) - exported: list[dict] = [] + exported: list[dict[str, Any]] = [] self._run_stop(rollout, "current", exported) @@ -1054,7 +1095,7 @@ def _run_stop( self, rollout: Path, turn_id: str, - exported: list[dict], + exported: list[dict[str, Any]], *, config: Config | None = None, ) -> None: @@ -1072,7 +1113,7 @@ def _run_stop( work_item_id="TEST-1", ) - def record_export(document: dict, _config: Config) -> int: + def record_export(document: dict[str, Any], _config: Config) -> int: exported.append(document) return len(document["traces"]) @@ -1095,7 +1136,7 @@ def _invoke_stop( cwd: Path, turn_id: str, config: Config, - exporter, + exporter: Callable[[dict[str, Any], Config], int], ) -> int: payload = { "session_id": "session-main", @@ -1127,7 +1168,7 @@ def _git(repo: Path, *arguments: str) -> str: ).stdout.strip() @staticmethod - def _write_rollout(rollout: Path, records: list[dict]) -> None: + def _write_rollout(rollout: Path, records: list[dict[str, Any]]) -> None: rollout.write_text( "".join(f"{json.dumps(record)}\n" for record in records), encoding="utf-8", diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/test_config.py b/plugins/capability/darrow-observability-langfuse/backend/tests/test_config.py index 06b6aa68..fa78314d 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/tests/test_config.py +++ b/plugins/capability/darrow-observability-langfuse/backend/tests/test_config.py @@ -14,7 +14,7 @@ class ConfigTest(unittest.TestCase): - def test_environment_overrides_repository_and_user_files(self): + def test_environment_overrides_repository_and_user_files(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) home = root / "home" @@ -48,28 +48,61 @@ def test_environment_overrides_repository_and_user_files(self): self.assertEqual(config.public_key, "pk-test") self.assertEqual(config.secret_key, "sk-test") - def test_branch_inference_handles_ticket_and_absent_states(self): + def test_branch_inference_handles_ticket_and_absent_states(self) -> None: with tempfile.TemporaryDirectory() as directory: repo = Path(directory) subprocess.run(["git", "init", "-q", str(repo)], check=True) subprocess.run( - ["git", "-C", str(repo), "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-q", "--allow-empty", "-m", "init"], + [ + "git", + "-C", + str(repo), + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "-q", + "--allow-empty", + "-m", + "init", + ], check=True, ) - subprocess.run(["git", "-C", str(repo), "checkout", "-q", "-b", "feat/DAR-123-export"], check=True) + subprocess.run( + ["git", "-C", str(repo), "checkout", "-q", "-b", "feat/DAR-123-export"], + check=True, + ) self.assertEqual(infer_work_item_id(str(repo)), "DAR-123") - subprocess.run(["git", "-C", str(repo), "checkout", "-q", "-B", "feat/45-export"], check=True) + subprocess.run( + ["git", "-C", str(repo), "checkout", "-q", "-B", "feat/45-export"], + check=True, + ) self.assertEqual(infer_work_item_id(str(repo)), "45") - subprocess.run(["git", "-C", str(repo), "checkout", "-q", "-B", "feat/langfuse-export"], check=True) + subprocess.run( + [ + "git", + "-C", + str(repo), + "checkout", + "-q", + "-B", + "feat/langfuse-export", + ], + check=True, + ) self.assertIsNone(infer_work_item_id(str(repo))) - subprocess.run(["git", "-C", str(repo), "checkout", "-q", "--detach", "HEAD"], check=True) + subprocess.run( + ["git", "-C", str(repo), "checkout", "-q", "--detach", "HEAD"], + check=True, + ) self.assertIsNone(infer_work_item_id(str(repo))) - def test_branch_inference_uses_only_the_exact_leading_token(self): + def test_branch_inference_uses_only_the_exact_leading_token(self) -> None: self.assertEqual( infer_work_item_id_from_branch("fix/issue-64-preserve-ticket-identifiers"), "issue-64", @@ -85,5 +118,6 @@ def test_branch_inference_uses_only_the_exact_leading_token(self): "issue-64", ) + if __name__ == "__main__": unittest.main() diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/test_delivery.py b/plugins/capability/darrow-observability-langfuse/backend/tests/test_delivery.py index f7eaf85e..72a05290 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/tests/test_delivery.py +++ b/plugins/capability/darrow-observability-langfuse/backend/tests/test_delivery.py @@ -2,87 +2,130 @@ import json import multiprocessing -import os import socket +import sqlite3 import tempfile import threading import time import unittest import uuid +from collections.abc import Iterator from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from io import StringIO from pathlib import Path +from typing import Any, ClassVar from unittest.mock import patch -from darrow_observability_langfuse.capture import capture, database, delivery_rows +from darrow_observability_langfuse.capture import capture, delivery_rows from darrow_observability_langfuse.cli import run from darrow_observability_langfuse.config import Config from darrow_observability_langfuse.delivery import await_capture, drain -from darrow_observability_langfuse.export import DeliveryFailure, export_document +from darrow_observability_langfuse.export import DeliveryError, export_document -def rollout(path, count=1): +class _EndpointHandler(BaseHTTPRequestHandler): + mode = "ok" + delay = 0.0 + recorded: ClassVar[list[tuple[str, dict[str, str], bytes]]] = [] + + def log_message(self, _format: str, *_args: Any) -> None: + return None + + def do_POST(self) -> None: + data = self.rfile.read(int(self.headers["Content-Length"])) + self.recorded.append((self.path, dict(self.headers), data)) + if self.delay: + time.sleep(self.delay) + if self._close_lost_connection(): + return + self.send_response(401 if self.mode == "reject" else 200) + self._write_response() + + def _close_lost_connection(self) -> bool: + if self.mode != "loss": + return False + self.connection.shutdown(socket.SHUT_RDWR) + self.connection.close() + return True + + def _write_response(self) -> None: + if self.mode in {"json", "queue", "queue-partial"}: + self.send_header("Content-Type", "application/json; charset=utf-8") + self.end_headers() + bodies = { + "json": b"{}", + "queue": b'{"name":"otel-ingestion-job","data":{"id":"accepted-job"}}', + "queue-partial": b'{"name":"otel-ingestion-job","data":{"id":"accepted-job"},"partialSuccess":{"rejectedSpans":1}}', + } + if self.mode in bodies: + self.wfile.write(bodies[self.mode]) + if self.mode == "partial": + self.wfile.write(_partial_response()) + + +def _partial_response() -> bytes: + from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ( + ExportTraceServiceResponse, + ) + + result = ExportTraceServiceResponse() + result.partial_success.rejected_spans = 1 + return result.SerializeToString() + + +def rollout(path: Path, count: int = 1) -> None: records = [{"type": "session_meta", "payload": {"id": "session"}}] for index in range(count): - records.extend([ - {"type": "event_msg", "payload": {"type": "task_started", "turn_id": str(index)}}, - {"type": "event_msg", "payload": {"type": "task_complete"}}, - ]) + records.extend( + [ + { + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": str(index)}, + }, + {"type": "event_msg", "payload": {"type": "task_complete"}}, + ] + ) path.write_text("".join(json.dumps(row) + "\n" for row in records)) -def capture_worker(path, turn): - capture(Path(path), Config(enabled=True), str(Path(path).parent), "session", turn, None) +def capture_worker(path: str, turn: str) -> None: + capture( + Path(path), Config(enabled=True), str(Path(path).parent), "session", turn, None + ) -def terminate_worker(path, config, started): - def exporter(document, config): +def terminate_worker(path: str, config: Config, started: Any) -> None: + def exporter(_document: dict[str, Any], _config: Config) -> int: started.set() time.sleep(30) + return 0 + drain(Path(path), config, cwd=str(Path(path).parent), exporter=exporter) -def terminate_capture_worker(path, config, started): +def terminate_capture_worker(path: str, config: Config, started: Any) -> None: from darrow_observability_langfuse import capture as module - def paused(*args): + + def paused(connection: sqlite3.Connection, key: str, value: Any) -> None: started.set() time.sleep(30) + module._put = paused module.capture(Path(path), config, str(Path(path).parent), "session", "0", None) @contextmanager -def endpoint(mode="ok", delay=0): - requests = [] - class Handler(BaseHTTPRequestHandler): - def log_message(self, *args): - pass - def do_POST(self): - data = self.rfile.read(int(self.headers["Content-Length"])) - requests.append((self.path, dict(self.headers), data)) - if delay: - time.sleep(delay) - if mode == "loss": - self.connection.shutdown(socket.SHUT_RDWR) - self.connection.close() - return - self.send_response(401 if mode == "reject" else 200) - if mode in {"json", "queue", "queue-partial"}: - self.send_header("Content-Type", "application/json; charset=utf-8") - self.end_headers() - if mode == "json": - self.wfile.write(b"{}") - if mode == "queue": - self.wfile.write(b'{"name":"otel-ingestion-job","data":{"id":"accepted-job"}}') - if mode == "queue-partial": - self.wfile.write(b'{"name":"otel-ingestion-job","data":{"id":"accepted-job"},"partialSuccess":{"rejectedSpans":1}}') - if mode == "partial": - from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceResponse - result = ExportTraceServiceResponse() - result.partial_success.rejected_spans = 1 - self.wfile.write(result.SerializeToString()) - server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) +def endpoint( + mode: str = "ok", delay: float = 0 +) -> Iterator[tuple[str, list[tuple[str, dict[str, str], bytes]]]]: + requests: list[tuple[str, dict[str, str], bytes]] = [] + handler = type( + "EndpointHandler", + (_EndpointHandler,), + {"mode": mode, "delay": delay, "recorded": requests}, + ) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: @@ -94,56 +137,99 @@ def do_POST(self): class DeliveryTest(unittest.TestCase): - def setUp(self): + def setUp(self) -> None: self.directory = tempfile.TemporaryDirectory() self.path = Path(self.directory.name) / "rollout.jsonl" rollout(self.path) - def tearDown(self): + + def tearDown(self) -> None: self.directory.cleanup() - def config(self, url="http://127.0.0.1:1"): - return Config(enabled=True, strict=True, public_key="pk-" + uuid.uuid4().hex, - secret_key="sk-private", base_url=url) - def capture(self, config): + + def config(self, url: str = "http://127.0.0.1:1") -> Config: + return Config( + enabled=True, + strict=True, + public_key="pk-" + uuid.uuid4().hex, + secret_key="sk-private", + base_url=url, + ) + + def capture(self, config: Config) -> dict[str, int]: return capture(self.path, config, self.directory.name, "session", "0", None) - def test_session_start_and_prompt_hooks_recover_pending_work(self): + def test_session_start_and_prompt_hooks_recover_pending_work(self) -> None: for event in ("SessionStart", "UserPromptSubmit"): with self.subTest(event=event), tempfile.TemporaryDirectory() as directory: path = Path(directory) / "rollout.jsonl" rollout(path) config = self.config() capture(path, config, directory, "session", "0", None) - payload = {"hook_event_name": event, "session_id": "session", - "cwd": directory, "transcript_path": str(path)} - with (patch("sys.stdin", StringIO(json.dumps(payload))), - patch("darrow_observability_langfuse.cli.load_config", return_value=config), - patch("darrow_observability_langfuse.cli.export_document", return_value=1) as exporter): + payload = { + "hook_event_name": event, + "session_id": "session", + "cwd": directory, + "transcript_path": str(path), + } + with ( + patch("sys.stdin", StringIO(json.dumps(payload))), + patch( + "darrow_observability_langfuse.cli.load_config", + return_value=config, + ), + patch( + "darrow_observability_langfuse.cli.export_document", + return_value=1, + ) as exporter, + ): self.assertEqual(run(background=True), 0) exporter.assert_called_once() self.assertEqual(delivery_rows(path)[0]["state"], "acknowledged") - def test_foreground_never_contacts_delayed_or_unavailable_endpoint(self): + def test_foreground_never_contacts_delayed_or_unavailable_endpoint(self) -> None: for delay in (0, 0.5): with endpoint(delay=delay) as (url, requests): config = self.config(url) self.path = Path(self.directory.name) / f"rollout-{delay}.jsonl" rollout(self.path) - payload = {"hook_event_name": "Stop", "session_id": "session", "turn_id": "0", - "cwd": self.directory.name, "transcript_path": str(self.path)} - with patch("sys.stdin", StringIO(json.dumps(payload))), patch("darrow_observability_langfuse.cli.load_config", return_value=config): + payload = { + "hook_event_name": "Stop", + "session_id": "session", + "turn_id": "0", + "cwd": self.directory.name, + "transcript_path": str(self.path), + } + with ( + patch("sys.stdin", StringIO(json.dumps(payload))), + patch( + "darrow_observability_langfuse.cli.load_config", + return_value=config, + ), + ): start = time.monotonic() self.assertEqual(run(), 0) self.assertLess(time.monotonic() - start, 0.5) self.assertEqual(requests, []) self.assertEqual(delivery_rows(self.path)[0]["state"], "pending") - def test_real_otlp_batch_headers_and_large_queue(self): - from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceRequest + def test_real_otlp_batch_headers_and_large_queue(self) -> None: + from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ( + ExportTraceServiceRequest, + ) + with endpoint() as (url, requests): config = self.config(url) for count in (200, 3000): requests.clear() - document = {"traces": [{"name": "root", "observations": [{"name": "tool", "type": "tool"} for _ in range(count)]}]} + document = { + "traces": [ + { + "name": "root", + "observations": [ + {"name": "tool", "type": "tool"} for _ in range(count) + ], + } + ] + } self.assertEqual(export_document(document, config), 1) sizes = [] for path, headers, body in requests: @@ -151,15 +237,33 @@ def test_real_otlp_batch_headers_and_large_queue(self): self.assertEqual(headers["x-langfuse-ingestion-version"], "4") self.assertIn("x-langfuse-sdk-version", headers) message = ExportTraceServiceRequest.FromString(body) - sizes.append(sum(len(scope.spans) for resource in message.resource_spans for scope in resource.scope_spans)) + sizes.append( + sum( + len(scope.spans) + for resource in message.resource_spans + for scope in resource.scope_spans + ) + ) self.assertEqual(sum(sizes), count + 1) self.assertTrue(all(size <= 512 for size in sizes)) if count == 200: self.assertEqual(sizes, [201]) - def test_http_rejection_response_loss_partial_ingestion_and_success(self): - for mode, expected in (("reject", "pending"), ("loss", "uncertain"), ("partial", "uncertain"), ("queue-partial", "uncertain"), ("ok", "acknowledged"), ("json", "acknowledged"), ("queue", "acknowledged")): - with self.subTest(mode=mode), tempfile.TemporaryDirectory() as directory, endpoint(mode) as (url, requests): + def test_http_rejection_response_loss_partial_ingestion_and_success(self) -> None: + for mode, expected in ( + ("reject", "pending"), + ("loss", "uncertain"), + ("partial", "uncertain"), + ("queue-partial", "uncertain"), + ("ok", "acknowledged"), + ("json", "acknowledged"), + ("queue", "acknowledged"), + ): + with ( + self.subTest(mode=mode), + tempfile.TemporaryDirectory() as directory, + endpoint(mode) as (url, requests), + ): path = Path(directory) / "rollout.jsonl" rollout(path) config = self.config(url) @@ -167,7 +271,7 @@ def test_http_rejection_response_loss_partial_ingestion_and_success(self): if mode in {"ok", "json", "queue"}: self.assertEqual(drain(path, config, cwd=directory), 1) else: - with self.assertRaises(DeliveryFailure): + with self.assertRaises(DeliveryError): drain(path, config, cwd=directory) row = delivery_rows(path)[0] self.assertEqual(row["state"], expected) @@ -177,21 +281,31 @@ def test_http_rejection_response_loss_partial_ingestion_and_success(self): self.assertEqual(drain(path, config, cwd=directory), 0) self.assertEqual(len(requests), 1) - def test_connection_refusal_is_retryable_and_snapshot_is_stable(self): + def test_connection_refusal_is_retryable_and_snapshot_is_stable(self) -> None: config = self.config() self.capture(config) before = delivery_rows(self.path)[0] - with self.assertRaises(DeliveryFailure) as caught: + with self.assertRaises(DeliveryError) as caught: drain(self.path, config, cwd=self.directory.name) self.assertEqual(caught.exception.outcome, "pending") self.assertEqual(delivery_rows(self.path)[0]["document"], before["document"]) - self.assertEqual(drain(self.path, config, cwd=self.directory.name, exporter=lambda doc, cfg: len(doc["traces"])), 1) + self.assertEqual( + drain( + self.path, + config, + cwd=self.directory.name, + exporter=lambda doc, cfg: len(doc["traces"]), + ), + 1, + ) - def test_invalid_url_before_request_remains_pending_without_destination_rebinding(self): + def test_invalid_url_before_request_remains_pending_without_destination_rebinding( + self, + ) -> None: config = self.config("not-a-url") self.capture(config) before = delivery_rows(self.path)[0] - with self.assertRaises(DeliveryFailure) as caught: + with self.assertRaises(DeliveryError) as caught: drain(self.path, config, cwd=self.directory.name) self.assertEqual(caught.exception.outcome, "pending") self.assertEqual(delivery_rows(self.path)[0]["state"], "pending") @@ -200,33 +314,51 @@ def test_invalid_url_before_request_remains_pending_without_destination_rebindin drain(self.path, self.config(url), cwd=self.directory.name) self.assertEqual(requests, []) after = delivery_rows(self.path)[0] - self.assertEqual((after["identity"], after["document"], after["expected_count"]), - (before["identity"], before["document"], before["expected_count"])) + self.assertEqual( + (after["identity"], after["document"], after["expected_count"]), + (before["identity"], before["document"], before["expected_count"]), + ) - def test_single_drainer_does_not_block_foreground_capture(self): + def test_single_drainer_does_not_block_foreground_capture(self) -> None: config = self.config() self.capture(config) started, finish = threading.Event(), threading.Event() - def slow_export(doc, cfg): + + def slow_export(doc: dict[str, Any], _config: Config) -> int: started.set() finish.wait(5) return len(doc["traces"]) - thread = threading.Thread(target=lambda: drain(self.path, config, cwd=self.directory.name, exporter=slow_export)) + + thread = threading.Thread( + target=lambda: drain( + self.path, config, cwd=self.directory.name, exporter=slow_export + ) + ) thread.start() try: self.assertTrue(started.wait(2)) - self.assertEqual(drain(self.path, config, cwd=self.directory.name, exporter=lambda *args: self.fail("second drainer exported")), 0) + self.assertEqual( + drain( + self.path, + config, + cwd=self.directory.name, + exporter=lambda *args: self.fail("second drainer exported"), + ), + 0, + ) self.assertEqual(self.capture(config)["bytes_read"], 0) finally: finish.set() thread.join() - def test_terminated_delivery_is_uncertain_and_backlog_recovers(self): + def test_terminated_delivery_is_uncertain_and_backlog_recovers(self) -> None: config = self.config() self.capture(config) context = multiprocessing.get_context("spawn") started = context.Event() - process = context.Process(target=terminate_worker, args=(str(self.path), config, started)) + process = context.Process( + target=terminate_worker, args=(str(self.path), config, started) + ) process.start() self.assertTrue(started.wait(5)) process.terminate() @@ -236,14 +368,27 @@ def test_terminated_delivery_is_uncertain_and_backlog_recovers(self): rollout(self.path, 3) capture(self.path, config, self.directory.name, "session", "1", None) capture(self.path, config, self.directory.name, "session", "2", None) - self.assertEqual(drain(self.path, config, cwd=self.directory.name, exporter=lambda doc, cfg: len(doc["traces"])), 2) - self.assertEqual([row["state"] for row in delivery_rows(self.path)], ["uncertain", "acknowledged", "acknowledged"]) + self.assertEqual( + drain( + self.path, + config, + cwd=self.directory.name, + exporter=lambda doc, cfg: len(doc["traces"]), + ), + 2, + ) + self.assertEqual( + [row["state"] for row in delivery_rows(self.path)], + ["uncertain", "acknowledged", "acknowledged"], + ) - def test_terminated_capture_rolls_back_atomic_local_state(self): + def test_terminated_capture_rolls_back_atomic_local_state(self) -> None: config = self.config() context = multiprocessing.get_context("spawn") started = context.Event() - process = context.Process(target=terminate_capture_worker, args=(str(self.path), config, started)) + process = context.Process( + target=terminate_capture_worker, args=(str(self.path), config, started) + ) process.start() self.assertTrue(started.wait(5)) process.terminate() @@ -252,13 +397,18 @@ def test_terminated_capture_rolls_back_atomic_local_state(self): self.assertEqual(delivery_rows(self.path), []) self.assertEqual(self.capture(config)["bytes_read"], self.path.stat().st_size) - def test_reordered_concurrent_capture_and_early_background_hook(self): + def test_reordered_concurrent_capture_and_early_background_hook(self) -> None: rollout(self.path, 8) ready = [] - thread = threading.Thread(target=lambda: ready.append(await_capture(self.path, "7", timeout=5))) + thread = threading.Thread( + target=lambda: ready.append(await_capture(self.path, "7", timeout=5)) + ) thread.start() context = multiprocessing.get_context("spawn") - processes = [context.Process(target=capture_worker, args=(str(self.path), str(index))) for index in (7, 0, 4, 7)] + processes = [ + context.Process(target=capture_worker, args=(str(self.path), str(index))) + for index in (7, 0, 4, 7) + ] for process in processes: process.start() for process in processes: @@ -268,8 +418,19 @@ def test_reordered_concurrent_capture_and_early_background_hook(self): self.assertEqual(ready, [True]) self.assertEqual(len(delivery_rows(self.path)), 1) from darrow_observability_langfuse.lifecycle import record_terminal - record_terminal(self.path, "session", "SessionEnd", None, Config(enabled=True), None, cwd=self.directory.name) - capture(self.path, Config(enabled=True), self.directory.name, "session", None, None) + + record_terminal( + self.path, + "session", + "SessionEnd", + None, + Config(enabled=True), + None, + cwd=self.directory.name, + ) + capture( + self.path, Config(enabled=True), self.directory.name, "session", None, None + ) self.assertEqual(len(delivery_rows(self.path)), 8) diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/test_edge_cases.py b/plugins/capability/darrow-observability-langfuse/backend/tests/test_edge_cases.py new file mode 100644 index 00000000..8b34caf6 --- /dev/null +++ b/plugins/capability/darrow-observability-langfuse/backend/tests/test_edge_cases.py @@ -0,0 +1,771 @@ +from __future__ import annotations + +import io +import json +import os +import runpy +import sqlite3 +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import patch + +import requests + +from darrow_observability_langfuse import ( + cli, + config, + delivery, + export, + lifecycle, + rollout, + sidecar, +) +from darrow_observability_langfuse.config import Config +from darrow_observability_langfuse.context import delivery_context +from darrow_observability_langfuse.export import DeliveryError + + +class ConfigEdgeTests(unittest.TestCase): + def test_boolean_and_file_validation(self) -> None: + self.assertTrue(config._parse_bool(True, "enabled")) + self.assertFalse(config._parse_bool(" off ", "enabled")) + for invalid in (1, "maybe"): + with ( + self.subTest(invalid=invalid), + self.assertRaisesRegex(ValueError, "must be a boolean"), + ): + config._parse_bool(invalid, "enabled") + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "config.json" + self.assertEqual(config._read_config_file(path), {}) + path.write_text("not-json", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "unreadable or invalid"): + config._read_config_file(path) + path.write_text("[]", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "must contain an object"): + config._read_config_file(path) + + def test_numeric_config_validation(self) -> None: + with tempfile.TemporaryDirectory() as directory: + for value in ("invalid", -1, 1_000_001): + with ( + self.subTest(value=value), + self.assertRaisesRegex(ValueError, "max_chars"), + ): + config.load_config( + directory, + home=directory, + env={"DARROW_LANGFUSE_MAX_CHARS": str(value)}, + ) + + def test_git_failures_and_explicit_work_item_resolution(self) -> None: + with patch( + "darrow_observability_langfuse.config.subprocess.run", side_effect=OSError + ): + self.assertIsNone(config._git_output("/missing", "status")) + self.assertEqual( + config.resolve_work_item_id(Config(work_item_id="ISSUE-1"), "/missing"), + "ISSUE-1", + ) + + +class LifecycleEdgeTests(unittest.TestCase): + def test_registry_validation_rejects_each_invalid_shape(self) -> None: + values: list[Any] = [ + None, + {"rollout": "relative", "session_id": "session"}, + {"rollout": "/absolute", "session_id": ""}, + ] + for value in values: + with ( + self.subTest(value=value), + self.assertRaisesRegex(ValueError, "registry is invalid"), + ): + lifecycle._validate_registry_entry(value) + + def test_terminal_validation_rejects_each_invalid_shape(self) -> None: + valid = { + "session_id": "session", + "event": "SessionEnd", + "identity": [1, 2, 3], + "offset": 0, + "capture_content": False, + } + values: list[Any] = [ + None, + {**valid, "session_id": "other"}, + {**valid, "event": "Unknown"}, + {**valid, "offset": -1}, + {**valid, "capture_content": "false"}, + {**valid, "event": "Interrupt", "turn_id": None}, + ] + for value in values: + with ( + self.subTest(value=value), + self.assertRaisesRegex(ValueError, "receipt is invalid"), + ): + lifecycle._validate_terminal_record(value, "session") + + def test_terminal_input_and_no_plugin_registry(self) -> None: + self.assertEqual(list(lifecycle.registered_rollouts(None, {})), []) + lifecycle.register_rollout(Path("/tmp/rollout"), "session", None, {}) + with self.assertRaisesRegex(ValueError, "invalid terminal hook"): + lifecycle.record_terminal( + Path("/missing"), + "session", + "Other", + None, + Config(), + None, + cwd="/tmp", + ) + with self.assertRaisesRegex(ValueError, "readable absolute file"): + lifecycle.record_terminal( + Path("relative"), + "session", + "SessionEnd", + None, + Config(), + None, + cwd="/tmp", + ) + + def test_legacy_context_binding_and_unbound_evidence(self) -> None: + with tempfile.TemporaryDirectory() as directory: + rollout_path = Path(directory) / "rollout.jsonl" + database_path = Path(f"{rollout_path}.darrow-langfuse.sqlite3") + context = delivery_context(Config(), directory) + connection = sqlite3.connect(database_path) + for table in ("envelopes", "captured_turns", "receipts", "snapshots"): + connection.execute(f"CREATE TABLE {table} (value TEXT)") + connection.execute("CREATE TABLE state (key TEXT, value TEXT)") + connection.execute( + "INSERT INTO state VALUES ('delivery_context', ?)", + (json.dumps(context),), + ) + connection.commit() + connection.close() + lifecycle._validate_legacy_context(rollout_path, context) + + connection = sqlite3.connect(database_path) + connection.execute("DELETE FROM state") + connection.execute("INSERT INTO envelopes VALUES ('evidence')") + connection.commit() + connection.close() + with self.assertRaisesRegex(ValueError, "context"): + lifecycle._validate_legacy_context(rollout_path, context) + + +class SidecarEdgeTests(unittest.TestCase): + def test_provisional_path_and_state_validation(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for identifier in ("", "x" * 257, "bad\nvalue"): + with ( + self.subTest(identifier=identifier), + self.assertRaisesRegex(ValueError, "invalid session ID"), + ): + sidecar._provisional_path(root, identifier) + path = root / "state.json" + path.write_text("bad", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "unreadable or invalid"): + sidecar._load_state_path(path) + path.write_text("[]", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "unreadable or invalid"): + sidecar._load_state_path(path) + + def test_sidecar_version_and_identifier_validation(self) -> None: + versions: list[dict[str, Any]] = [ + {"attribution_snapshots": {}}, + {"version": 2, "uploaded_turn_ids": [], "attribution_snapshots": {}}, + ] + for version in versions: + with self.subTest(value=version), self.assertRaises(ValueError): + sidecar._validate_version(version) + identifiers: list[Any] = [None, [""], ["bad\nvalue"]] + for identifier in identifiers: + with ( + self.subTest(value=identifier), + self.assertRaisesRegex(ValueError, "uploaded_turn_ids"), + ): + sidecar._validated_identifiers(identifier) + + def test_snapshot_validation_rejects_invalid_fields(self) -> None: + with self.assertRaisesRegex(ValueError, "attribution_snapshots"): + sidecar._validated_snapshots([]) + invalid = [ + ("", {}), + ("turn", []), + ("turn", {"source": "other", "work_item_id": None}), + ("turn", {"source": "none", "work_item_id": "ISSUE-1"}), + ("turn", {"source": "configuration", "work_item_id": "bad value"}), + ( + "turn", + { + "source": "git_branch", + "work_item_id": "ISSUE-1", + "branch": None, + }, + ), + ] + for turn_id, snapshot in invalid: + with ( + self.subTest(snapshot=snapshot), + self.assertRaisesRegex(ValueError, "attribution snapshot"), + ): + sidecar._validate_snapshot(turn_id, snapshot) + + def test_pending_and_export_validation(self) -> None: + with tempfile.TemporaryDirectory() as directory: + rollout_path = Path(directory) / "rollout.jsonl" + with self.assertRaisesRegex(ValueError, "missing traces"): + sidecar.pending_document({}, rollout_path) + with self.assertRaisesRegex(ValueError, "missing traces"): + sidecar.mark_exported_turns(rollout_path, {}) + sidecar.mark_exported_turns(rollout_path, {"traces": [{"metadata": {}}]}) + document = { + "traces": [ + None, + {"metadata": {"codex.completed": False}}, + { + "metadata": { + "codex.completed": True, + "codex.turn_id": "turn-1", + } + }, + ] + } + sidecar.mark_exported_turns(rollout_path, document) + self.assertEqual( + sidecar.pending_document(document, rollout_path)["traces"], [] + ) + + def test_provisional_discard_empty_and_remaining(self) -> None: + snapshot = { + "source": "configuration", + "work_item_id": "ISSUE-1", + "branch": None, + "head": None, + } + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + sidecar.discard_provisional_attribution_snapshots(root, "session", set()) + sidecar.record_provisional_attribution_snapshot( + root, "session", "one", snapshot + ) + sidecar.record_provisional_attribution_snapshot( + root, "session", "two", snapshot + ) + sidecar.discard_provisional_attribution_snapshots(root, "session", {"one"}) + self.assertEqual( + set(sidecar.load_provisional_attribution_snapshots(root, "session")), + {"two"}, + ) + + +class DeliveryAndExportEdgeTests(unittest.TestCase): + def test_empty_delivery_and_busy_lock(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.assertEqual( + delivery.drain(root / "missing", Config(), cwd=directory), 0 + ) + with ( + patch( + "darrow_observability_langfuse.delivery.fcntl.flock", + side_effect=BlockingIOError, + ), + delivery._exclusive_lock(root / "lock") as acquired, + ): + self.assertFalse(acquired) + + def test_delivery_envelope_validation(self) -> None: + rows = cast( + "list[sqlite3.Row]", + [{"expected_count": 2, "identity": "expected"}], + ) + document = { + "traces": [ + { + "metadata": { + "darrow.expected_observation_count": 1, + "darrow.delivery_id": "other", + }, + "observations": [], + } + ] + } + with self.assertRaisesRegex(ValueError, "envelope evidence"): + delivery._validate_envelopes(document, rows) + + def test_no_session_and_batch_boundaries(self) -> None: + with tempfile.TemporaryDirectory() as directory: + rollout_path = Path(directory) / "rollout.jsonl" + with ( + patch.object(delivery, "database_path", return_value=rollout_path), + patch.object(Path, "exists", return_value=True), + patch.object(delivery, "_session_id", return_value=None), + ): + self.assertEqual( + delivery.drain(rollout_path, Config(), cwd=directory), 0 + ) + + rows = [{"expected_count": 400}, {"expected_count": 200}] + connection = SimpleNamespace(execute=lambda *_args: iter(rows)) + drainer = delivery._Drainer(Path("/tmp/rollout"), Config(), lambda *_: 0) + self.assertEqual( + drainer._pending_rows(cast("sqlite3.Connection", connection)), [rows[0]] + ) + + def test_delivery_attempt_mismatch_and_cancellation(self) -> None: + batch = delivery._Batch([], {}, [], 0) + mismatch = delivery._Drainer(Path("/tmp/rollout"), Config(), lambda *_: 1) + with patch.object(mismatch, "_record_failure") as record_failure: + mismatch._attempt(batch) + self.assertIsInstance(mismatch.failure, DeliveryError) + record_failure.assert_called_once() + + def cancel(*_arguments: Any) -> int: + raise KeyboardInterrupt + + cancelled = delivery._Drainer(Path("/tmp/rollout"), Config(), cancel) + with self.assertRaises(KeyboardInterrupt): + cancelled._attempt(batch) + + def test_single_attempt_exporter_response_and_failure_classes(self) -> None: + exporter = export.SingleAttemptExporter(Config()) + self.assertTrue(exporter.force_flush()) + exporter.shutdown() + with self.assertRaisesRegex(DeliveryError, "HTTP rejection") as rejection: + exporter._reject_status(400) + self.assertEqual(rejection.exception.outcome, "pending") + exporter.accepted = 1 + with self.assertRaises(DeliveryError) as later_rejection: + exporter._reject_status(400) + self.assertEqual(later_rejection.exception.outcome, "uncertain") + self.assertEqual( + exporter._classify_failure(requests.exceptions.InvalidURL()).outcome, + "uncertain", + ) + self.assertEqual(exporter._classify_failure(ValueError()).outcome, "uncertain") + + def test_response_helpers_and_recursive_connection_detection(self) -> None: + self.assertTrue( + export._queued_response({"name": "otel-ingestion-job", "data": {"id": "1"}}) + ) + self.assertFalse(export._queued_response({"name": "other"})) + cyclic = RuntimeError("connection") + cyclic.__cause__ = cyclic + self.assertFalse(export._connection_refused(cyclic, set())) + export._parse_json_response( + {"name": "otel-ingestion-job", "data": {"id": "1"}}, object() + ) + self.assertEqual( + export._observation_attributes({"name": "plain"})["name"], "plain" + ) + error = export._observation_attributes({"name": "failed", "error": "boom"}) + self.assertEqual(error["level"], "ERROR") + + def test_trace_and_recording_validation(self) -> None: + with self.assertRaisesRegex(ValueError, "missing traces"): + export._validated_traces({}) + with self.assertRaisesRegex(ValueError, "invalid trace"): + export._validated_traces({"traces": [None]}) + export._verify_recording(None, 0) + recording = SimpleNamespace(failed=lambda: False, count=1) + with self.assertRaisesRegex(DeliveryError, "count was not confirmed"): + export._verify_recording(recording, 2) + + def test_recording_exporter_short_circuit_and_cached_config_refusal(self) -> None: + failed_delegate = SimpleNamespace( + failure=DeliveryError("pending", "failed"), + accepted=0, + export=lambda _spans: None, + force_flush=lambda **_kwargs: True, + shutdown=lambda: None, + ) + recording = export._RecordingExporter( + cast("export.SingleAttemptExporter", failed_delegate) + ) + recording.export([]) + self.assertTrue(recording.force_flush()) + recording.shutdown() + + original = dict(export._CLIENTS) + export._CLIENTS["pk"] = (object(), recording, Config(public_key="pk")) + try: + with self.assertRaisesRegex(ValueError, "configuration changed"): + export._new_recording_client( + Config(public_key="pk", base_url="https://different.example") + ) + finally: + export._CLIENTS.clear() + export._CLIENTS.update(original) + + +class CliEdgeTests(unittest.TestCase): + def test_hook_input_and_identifier_validation(self) -> None: + with ( + patch( + "darrow_observability_langfuse.cli.sys.stdin", io.StringIO("not-json") + ), + self.assertRaisesRegex(ValueError, "not valid JSON"), + ): + cli._read_hook_input() + with ( + patch("darrow_observability_langfuse.cli.sys.stdin", io.StringIO("[]")), + self.assertRaisesRegex(ValueError, "JSON object"), + ): + cli._read_hook_input() + with self.assertRaisesRegex(ValueError, "missing value"): + cli._required_identifier(None, "value") + + def test_trace_completion_and_completed_identifier_filter(self) -> None: + with self.assertRaisesRegex(ValueError, "missing turn_id"): + cli._complete_stop_turn({}, None) + with self.assertRaisesRegex(ValueError, "missing traces"): + cli._complete_stop_turn({}, "turn") + with self.assertRaisesRegex(ValueError, "exactly one"): + cli._complete_stop_turn({"traces": []}, "turn") + self.assertEqual(cli._completed_turn_ids({}), set()) + document = { + "traces": [ + None, + {"metadata": {"codex.completed": False, "codex.turn_id": "skip"}}, + {"metadata": {"codex.completed": True, "codex.turn_id": "done"}}, + ] + } + self.assertEqual(cli._completed_turn_ids(document), {"done"}) + completed = {"traces": [{"metadata": {"codex.turn_id": "turn"}}]} + cli._complete_stop_turn(completed, "turn") + self.assertTrue(completed["traces"][0]["metadata"]["codex.completed"]) + + def test_run_disabled_and_background_refusals(self) -> None: + with ( + patch("darrow_observability_langfuse.cli.sys.stdin", io.StringIO("{}")), + patch.object(cli, "load_config", return_value=Config()), + ): + self.assertEqual(cli.run(), 0) + runner = cli._HookRunner( + Config(enabled=True, public_key="pk", secret_key="sk"), + {"session_id": "session", "hook_event_name": "Unknown"}, + "/tmp", + None, + ) + with self.assertRaisesRegex(ValueError, "unsupported background"): + runner._background() + runner.event = "Stop" + runner.hook_input.update(transcript_path="/tmp/rollout", turn_id="turn") + with ( + patch.object(cli, "await_capture", return_value=False), + self.assertRaisesRegex(ValueError, "has not completed"), + ): + runner._background_stop() + + def test_hook_runner_refusal_and_dry_run_paths(self) -> None: + runner = cli._HookRunner( + Config(enabled=True, dry_run=True), + {"session_id": "session", "hook_event_name": "Unknown"}, + "/tmp", + None, + ) + with self.assertRaisesRegex(ValueError, "unsupported hook"): + runner.execute(False) + self.assertEqual(runner._background(), 0) + runner.event = "Interrupt" + self.assertEqual(runner._terminal(), 0) + runner.event = "UserPromptSubmit" + self.assertEqual(runner._prompt(), 0) + runner.config = Config(enabled=True) + with self.assertRaisesRegex(ValueError, "PLUGIN_DATA"): + runner._prompt() + with self.assertRaisesRegex(ValueError, "missing transcript_path"): + runner._absolute_rollout() + runner.hook_input["transcript_path"] = "relative" + with self.assertRaisesRegex(ValueError, "absolute"): + runner._absolute_rollout() + with self.assertRaisesRegex(ValueError, "credentials"): + runner._require_credentials() + runner.config = Config(enabled=True, dry_run=True) + runner.hook_input.update(transcript_path="/tmp/rollout", turn_id="turn") + with patch.object(runner, "_print_dry_run") as print_dry_run: + self.assertEqual(runner._stop(), 0) + print_dry_run.assert_called_once() + + def test_dry_run_document_uses_all_snapshot_sources(self) -> None: + runner = cli._HookRunner( + Config(enabled=True, dry_run=True), + { + "session_id": "session", + "hook_event_name": "Stop", + "turn_id": "turn", + "transcript_path": "/tmp/rollout", + }, + "/tmp", + Path("/tmp/plugin-data"), + ) + document = {"traces": [{"metadata": {"codex.turn_id": "turn"}}]} + output = io.StringIO() + with ( + patch.object(cli, "load_attribution_snapshots", return_value={}), + patch.object(cli, "load_capture_snapshots", return_value={}), + patch.object( + cli, + "load_provisional_attribution_snapshots", + return_value={"turn": {"source": "none"}}, + ), + patch.object(cli, "attribution_snapshot", return_value={"source": "none"}), + patch.object(cli, "trace_document", return_value=document), + patch("darrow_observability_langfuse.cli.sys.stdout", output), + ): + runner._print_dry_run(Path("/tmp/rollout"), "turn") + self.assertTrue(document["traces"][0]["metadata"]["codex.completed"]) + self.assertTrue(output.getvalue().endswith("\n")) + + def test_main_writes_launcher_status(self) -> None: + with tempfile.TemporaryDirectory() as directory: + receipt = Path(directory) / "status" + with ( + patch.object(cli, "run", return_value=0), + patch( + "darrow_observability_langfuse.cli.sys.argv", + ["cli", "--launcher-status", str(receipt)], + ), + self.assertRaises(SystemExit) as exit_status, + ): + cli.main() + self.assertEqual(exit_status.exception.code, 0) + self.assertEqual(receipt.read_text(encoding="ascii"), "0\n") + with ( + patch.object(cli, "run", return_value=0), + patch("darrow_observability_langfuse.cli.sys.argv", ["cli"]), + self.assertRaises(SystemExit), + ): + cli.main() + + def test_module_entrypoint(self) -> None: + module_name = "darrow_observability_langfuse.cli" + imported = sys.modules.pop(module_name) + try: + with ( + tempfile.TemporaryDirectory() as directory, + patch.object(sys, "stdin", io.StringIO("{}")), + patch.object(sys, "argv", ["cli"]), + patch.dict(os.environ, {"HOME": directory}), + self.assertRaises(SystemExit), + ): + runpy.run_module(module_name, run_name="__main__") + finally: + sys.modules[module_name] = imported + + +class RolloutEdgeTests(unittest.TestCase): + def test_content_helpers_cover_structural_variants(self) -> None: + self.assertEqual(rollout._clip(["abcd"], 2), ["ab\n…[truncated 2 chars]"]) + self.assertEqual( + rollout._clip({"api_key": "secret"}, 20), {"api_key": "[REDACTED]"} + ) + self.assertIsNone(rollout._message_text("not-a-list")) + self.assertEqual( + rollout._message_text([None, {"type": "input_text", "text": "hello"}]), + "hello", + ) + self.assertEqual(rollout._reasoning_text({"content": "reason"}), "reason") + self.assertIsNone(rollout._reasoning_text({"content": 1})) + self.assertEqual(rollout._reasoning_part("part"), "part") + self.assertIsNone(rollout._reasoning_part({"text": 1})) + self.assertEqual(rollout._parse_arguments({"value": 1}), {"value": 1}) + + def test_javascript_and_tool_label_helpers(self) -> None: + self.assertEqual(rollout._decode_js_string("'a\\nb'"), "a\nb") + self.assertEqual(rollout._decode_js_string('"unterminated'), "unterminate") + self.assertEqual(rollout._call_arguments("nested(one))", 0), "nested(one)") + self.assertIsNone(rollout._call_arguments("never(closes", 0)) + self.assertIsNone(rollout._command_from_arguments(1)) + self.assertIsNone(rollout._command_from_arguments("{value: 1}")) + self.assertEqual(rollout._invocation_label("tool", None), "tool") + self.assertEqual(rollout._nested_invocation_labels("tools.tool("), []) + self.assertEqual(rollout._quote_after_character('\\"', 1, '"'), '"') + self.assertEqual( + rollout._invocation_label("exec_command", {"cmd": "echo\nhello"}), + "echo hello", + ) + self.assertEqual( + rollout._invocation_label("exec_command", {"value": 1}), + 'exec_command {"value":1}', + ) + long_name = rollout._tool_observation_name( + {"name": "tool", "input": "x" * 600}, + Config(capture_content=True, max_chars=1_000), + ) + self.assertEqual(len(long_name), 512) + + def test_directive_usage_and_record_validation(self) -> None: + self.assertIsNone(rollout._attribution_directive(None)) + self.assertIsNone(rollout._attribution_directive("ordinary prompt")) + self.assertEqual( + rollout._attribution_directive("\n@darrow.attribution clear"), + ("clear", None), + ) + with self.assertRaisesRegex(ValueError, "invalid @darrow"): + rollout._attribution_directive("@darrow.attribution set") + self.assertIsNone(rollout._valid_usage(None)) + self.assertIsNone( + rollout._valid_usage( + {"input_tokens": 1, "output_tokens": 2, "total_tokens": 99} + ) + ) + self.assertIsNone( + rollout._valid_usage( + {"input_tokens": -1, "output_tokens": 1, "total_tokens": 0} + ) + ) + details = rollout._valid_usage( + { + "input_tokens": 2, + "output_tokens": 1, + "total_tokens": 3, + "cached_input_tokens": 1, + "reasoning_output_tokens": 2, + } + ) + self.assertEqual( + details, + { + "input_tokens": 2, + "output_tokens": 1, + "total_tokens": 3, + "cached_input_tokens": 1, + }, + ) + self.assertIsNone(rollout._parse_record("bad")) + self.assertIsNone(rollout._parse_record("[]")) + with self.assertRaisesRegex(ValueError, "readable absolute file"): + rollout.load_rollout(Path("relative")) + + def test_parser_ignores_unknown_and_handles_event_variants(self) -> None: + records = [ + {"timestamp": "1", "type": "unknown", "payload": {}}, + {"timestamp": "2", "type": "response_item", "payload": {"type": 1}}, + { + "timestamp": "3", + "type": "event_msg", + "payload": {"type": "agent_message", "message": "last"}, + }, + { + "timestamp": "4", + "type": "event_msg", + "payload": { + "type": "sub_agent_activity", + "kind": "started", + "agent_thread_id": "child", + }, + }, + { + "timestamp": "5", + "type": "event_msg", + "payload": {"type": "unknown_end", "call_id": "missing"}, + }, + { + "timestamp": "6", + "type": "event_msg", + "payload": {"type": "turn_aborted"}, + }, + ] + session, turns = rollout.parse_rollout(records) + self.assertIsNone(session["session_id"]) + self.assertTrue(turns[0]["aborted"]) + self.assertEqual(turns[0]["output"], "last") + self.assertEqual(turns[0]["subagent_thread_ids"], ["child"]) + + def test_parser_handles_messages_reasoning_and_tool_results(self) -> None: + records = [ + { + "timestamp": "1", + "type": "session_meta", + "payload": {"id": "session"}, + }, + { + "timestamp": "2", + "type": "response_item", + "payload": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "answer"}], + }, + }, + { + "timestamp": "3", + "type": "response_item", + "payload": { + "type": "function_call", + "call_id": "call", + "name": "tool", + "arguments": "{}", + }, + }, + { + "timestamp": "4", + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": "call", + "output": "done", + }, + }, + { + "timestamp": "5", + "type": "response_item", + "payload": {"type": "reasoning", "summary": [{"text": "why"}]}, + }, + { + "timestamp": "6", + "type": "event_msg", + "payload": {"type": "task_complete"}, + }, + ] + _session, turns = rollout.parse_rollout(records) + step = turns[0]["steps"][0] + self.assertEqual(step["output"], "answer") + self.assertEqual(step["reasoning"], "why") + self.assertEqual(step["tools"][0]["output"], "done") + + def test_trace_validation_and_attribution_variants(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "rollout.jsonl" + path.write_text("{}\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "thread ID"): + rollout.trace_document(path, Config(), directory, parsed=({}, [])) + timeline = rollout._AttributionTimeline( + Config(secret_key="SECRET"), + "thread", + {}, + None, + {"mode": "set", "explicit_work_item_id": "SECRET", "epoch": 0}, + ) + attribution = timeline.for_turn({"turn_id": "missing"}) + self.assertIsNone(attribution["work_item_id"]) + self.assertEqual(attribution["source"], "explicit") + clear = rollout._AttributionTimeline( + Config(), "thread", {}, None, {"mode": "clear"} + ).for_turn({"turn_id": "missing"}) + self.assertEqual(clear["source"], "explicit") + self.assertEqual( + rollout._subagent_observations( + path, + "missing", + Config(), + {}, + {path}, + None, + ), + [], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/test_export.py b/plugins/capability/darrow-observability-langfuse/backend/tests/test_export.py index 0d869b43..8c9fbc55 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/tests/test_export.py +++ b/plugins/capability/darrow-observability-langfuse/backend/tests/test_export.py @@ -1,86 +1,111 @@ from __future__ import annotations import unittest +from collections.abc import Iterator, Sequence from contextlib import contextmanager from pathlib import Path +from typing import Any from unittest.mock import patch from langfuse import Langfuse +from opentelemetry.sdk.trace import ReadableSpan from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult from darrow_observability_langfuse.config import Config from darrow_observability_langfuse.export import export_document from darrow_observability_langfuse.rollout import trace_document - PLUGIN_DIR = Path(__file__).resolve().parents[2] ROLLOUT = PLUGIN_DIR / "tests" / "fixtures" / "main-rollout.jsonl" +def _attribute(span: ReadableSpan, key: str) -> Any: + assert span.attributes is not None + return span.attributes.get(key) + + class CollectingSpanExporter(SpanExporter): - def __init__(self): - self.spans = [] + def __init__(self) -> None: + self.spans: list[ReadableSpan] = [] - def export(self, spans): + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: self.spans.extend(spans) return SpanExportResult.SUCCESS - def shutdown(self): - pass + def shutdown(self) -> None: + return None class FakeObservation: - def __init__(self, attributes: dict): + def __init__(self, attributes: dict[str, Any]) -> None: self.attributes = attributes self.children: list[FakeObservation] = [] self.ended = False - def start_observation(self, **attributes): + def start_observation(self, **attributes: Any) -> FakeObservation: child = FakeObservation(attributes) self.children.append(child) return child - def end(self, **_attributes): + def end(self, **_attributes: Any) -> None: self.ended = True class FakeClient: - def __init__(self): + def __init__(self) -> None: self.roots: list[FakeObservation] = [] self.flushed = False - def start_observation(self, **attributes): + def start_observation(self, **attributes: Any) -> FakeObservation: root = FakeObservation(attributes) self.roots.append(root) return root @contextmanager - def start_as_current_observation(self, **attributes): + def start_as_current_observation( + self, **attributes: Any + ) -> Iterator[FakeObservation]: root = self.start_observation(**attributes) try: yield root finally: root.end() - def flush(self): + def flush(self) -> None: self.flushed = True class ExportDocumentTest(unittest.TestCase): - def test_root_and_200_observations_use_one_bounded_batch(self): - batches = [] - config = Config(enabled=True, public_key="pk-batch-regression", secret_key="sk-test") - document = {"traces": [{"name": "Codex Turn", "observations": [ - {"name": "tool", "type": "tool"} for _ in range(200) - ]}]} + def test_root_and_200_observations_use_one_bounded_batch(self) -> None: + batches: list[int] = [] + config = Config( + enabled=True, public_key="pk-batch-regression", secret_key="sk-test" + ) + document = { + "traces": [ + { + "name": "Codex Turn", + "observations": [ + {"name": "tool", "type": "tool"} for _ in range(200) + ], + } + ] + } + + def record_batch(spans: Sequence[ReadableSpan]) -> SpanExportResult: + batches.append(len(spans)) + return SpanExportResult.SUCCESS + with patch( "darrow_observability_langfuse.export.SingleAttemptExporter.export", - side_effect=lambda spans: batches.append(len(spans)) or SpanExportResult.SUCCESS, + side_effect=record_batch, ): export_document(document, config) self.assertEqual(batches, [201]) - def _exported_spans(self, *, public_key, work_item_id=None): + def _exported_spans( + self, *, public_key: str, work_item_id: str | None = None + ) -> list[ReadableSpan]: config = Config( enabled=True, capture_content=False, @@ -102,7 +127,7 @@ def _exported_spans(self, *, public_key, work_item_id=None): export_document(document, config, client=client) return exporter.spans - def test_associates_session_observations_with_work_item(self): + def test_associates_session_observations_with_work_item(self) -> None: spans = self._exported_spans( public_key="pk-work-item-test", work_item_id="EXT-7", @@ -111,15 +136,13 @@ def test_associates_session_observations_with_work_item(self): self.assertGreater(len(spans), 1) self.assertEqual( { - span.attributes.get( - "langfuse.trace.metadata.darrow.work_item_id" - ) + _attribute(span, "langfuse.trace.metadata.darrow.work_item_id") for span in spans }, {"EXT-7"}, ) - def test_exports_epoch_as_session_and_thread_as_conversation_metadata(self): + def test_exports_epoch_as_session_and_thread_as_conversation_metadata(self) -> None: spans = self._exported_spans( public_key="pk-session-test", work_item_id="EXT-7", @@ -127,36 +150,32 @@ def test_exports_epoch_as_session_and_thread_as_conversation_metadata(self): self.assertGreater(len(spans), 1) self.assertEqual( - {span.attributes.get("session.id") for span in spans}, + {_attribute(span, "session.id") for span in spans}, {"session-main:attribution:0"}, ) self.assertEqual( { - span.attributes.get("langfuse.trace.metadata.codex.thread_id") + _attribute(span, "langfuse.trace.metadata.codex.thread_id") for span in spans }, {"session-main"}, ) self.assertEqual( { - span.attributes.get( - "langfuse.trace.metadata.darrow.attribution_epoch" - ) + _attribute(span, "langfuse.trace.metadata.darrow.attribution_epoch") for span in spans }, {"session-main:attribution:0"}, ) self.assertEqual( { - span.attributes.get( - "langfuse.trace.metadata.darrow.attribution_source" - ) + _attribute(span, "langfuse.trace.metadata.darrow.attribution_source") for span in spans }, {"configuration"}, ) - def test_exports_nested_trace_tree_through_langfuse_client(self): + def test_exports_nested_trace_tree_through_langfuse_client(self) -> None: config = Config( enabled=True, capture_content=True, @@ -176,14 +195,28 @@ def test_exports_nested_trace_tree_through_langfuse_client(self): self.assertEqual(root.attributes["name"], "Codex Turn") self.assertEqual(root.attributes["as_type"], "agent") self.assertEqual(root.attributes["metadata"]["darrow.work_item_id"], "EXT-7") - generation = next(child for child in root.children if child.attributes["as_type"] == "generation") + generation = next( + child + for child in root.children + if child.attributes["as_type"] == "generation" + ) self.assertEqual(generation.attributes["model"], "gpt-5.6-sol") self.assertEqual(generation.attributes["usage_details"]["total_tokens"], 120) - tool = next(child for child in generation.children if child.attributes["as_type"] == "tool") + tool = next( + child + for child in generation.children + if child.attributes["as_type"] == "tool" + ) self.assertEqual(tool.attributes["name"], "rg --files") - subagent = next(child for child in root.children if child.attributes["name"] == "Codex Subagent Turn") + subagent = next( + child + for child in root.children + if child.attributes["name"] == "Codex Subagent Turn" + ) self.assertEqual(subagent.attributes["as_type"], "agent") - self.assertTrue(root.ended and generation.ended and tool.ended and subagent.ended) + self.assertTrue( + root.ended and generation.ended and tool.ended and subagent.ended + ) if __name__ == "__main__": diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/test_lifecycle.py b/plugins/capability/darrow-observability-langfuse/backend/tests/test_lifecycle.py index f7c7209b..8a58afeb 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/tests/test_lifecycle.py +++ b/plugins/capability/darrow-observability-langfuse/backend/tests/test_lifecycle.py @@ -2,50 +2,105 @@ import json import os +import sqlite3 +import tempfile import threading import time -import tempfile import unittest +from collections.abc import Callable +from functools import partial from io import StringIO from pathlib import Path +from typing import Any from unittest.mock import patch +from darrow_observability_langfuse.capture import capture, database, delivery_rows from darrow_observability_langfuse.cli import run from darrow_observability_langfuse.config import Config -from darrow_observability_langfuse.capture import capture, database, delivery_rows from darrow_observability_langfuse.delivery import await_capture, drain from test_delivery import rollout +def _paused_put( + original: Callable[[sqlite3.Connection, str, Any], None], + started: threading.Event, + finish: threading.Event, + connection: sqlite3.Connection, + key: str, + value: Any, +) -> None: + if key == "delivery_context": + started.set() + if not finish.wait(5): + raise TimeoutError("capture test was not released") + original(connection, key, value) + + +def _foreground_capture( + path: Path, + config: Config, + root: Path, + plugin_data: Path, + failures: list[BaseException], +) -> None: + try: + capture(path, config, str(root), "session", "0", plugin_data) + except BaseException as error: + failures.append(error) + + class LifecycleTest(unittest.TestCase): - def setUp(self): + def setUp(self) -> None: self.directory = tempfile.TemporaryDirectory() self.root = Path(self.directory.name) self.path = self.root / "rollout.jsonl" self.plugin_data = self.root / "plugin-data" self.session = "session" - self.exports = [] + self.exports: list[dict[str, Any]] = [] - def tearDown(self): + def tearDown(self) -> None: self.directory.cleanup() - def invoke(self, event, turn=None, item="FINAL", background=False): - payload = {"hook_event_name": event, "session_id": self.session, - "cwd": str(self.root), "transcript_path": str(self.path)} + def invoke( + self, + event: str, + turn: str | None = None, + item: str = "FINAL", + background: bool = False, + ) -> int: + payload = { + "hook_event_name": event, + "session_id": self.session, + "cwd": str(self.root), + "transcript_path": str(self.path), + } if turn is not None: payload["turn_id"] = turn - config = Config(enabled=True, strict=True, public_key="pk-lifecycle", - secret_key="sk-lifecycle", work_item_id=item) - def export(document, config): + config = Config( + enabled=True, + strict=True, + public_key="pk-lifecycle", + secret_key="sk-lifecycle", + work_item_id=item, + ) + + def export(document: dict[str, Any], _config: Config) -> int: self.exports.extend(document["traces"]) return len(document["traces"]) - with (patch("sys.stdin", StringIO(json.dumps(payload))), - patch.dict(os.environ, {"PLUGIN_DATA": str(self.plugin_data)}), - patch("darrow_observability_langfuse.cli.load_config", return_value=config), - patch("darrow_observability_langfuse.cli.export_document", side_effect=export)): + + with ( + patch("sys.stdin", StringIO(json.dumps(payload))), + patch.dict(os.environ, {"PLUGIN_DATA": str(self.plugin_data)}), + patch("darrow_observability_langfuse.cli.load_config", return_value=config), + patch( + "darrow_observability_langfuse.cli.export_document", side_effect=export + ), + ): return run(background=background) - def test_out_of_order_stop_waits_for_final_attribution_and_ordered_epochs(self): + def test_out_of_order_stop_waits_for_final_attribution_and_ordered_epochs( + self, + ) -> None: rollout(self.path, 2) self.assertEqual(self.invoke("UserPromptSubmit", "0", "PROVISIONAL"), 0) self.assertEqual(self.invoke("Stop", "1", "LATER"), 0) @@ -53,19 +108,28 @@ def test_out_of_order_stop_waits_for_final_attribution_and_ordered_epochs(self): self.assertEqual(self.exports, []) self.assertEqual(self.invoke("Stop", "0", "AUTHORITATIVE"), 0) self.assertEqual(self.invoke("Stop", "0", background=True), 0) - self.assertEqual([trace["metadata"]["darrow.work_item_id"] for trace in self.exports], - ["AUTHORITATIVE", "LATER"]) - self.assertEqual([trace["session_id"] for trace in self.exports], - ["session:attribution:0", "session:attribution:1"]) + self.assertEqual( + [trace["metadata"]["darrow.work_item_id"] for trace in self.exports], + ["AUTHORITATIVE", "LATER"], + ) + self.assertEqual( + [trace["session_id"] for trace in self.exports], + ["session:attribution:0", "session:attribution:1"], + ) self.assertEqual(self.invoke("Stop", "0", "CHANGED"), 0) self.assertEqual(self.invoke("Stop", "0", background=True), 0) self.assertEqual(len(self.exports), 2) - def test_delayed_stop_completes_preindexed_turn_without_task_complete(self): + def test_delayed_stop_completes_preindexed_turn_without_task_complete(self) -> None: rollout(self.path, 2) records = [json.loads(line) for line in self.path.read_text().splitlines()] - self.path.write_text("".join(json.dumps(record) + "\n" for record in records - if record["payload"].get("type") != "task_complete")) + self.path.write_text( + "".join( + json.dumps(record) + "\n" + for record in records + if record["payload"].get("type") != "task_complete" + ) + ) self.assertEqual(self.invoke("UserPromptSubmit", "0", "PROVISIONAL"), 0) self.assertEqual(self.invoke("Stop", "1", "LATER"), 0) self.assertEqual(self.invoke("Stop", "1", background=True), 0) @@ -73,40 +137,54 @@ def test_delayed_stop_completes_preindexed_turn_without_task_complete(self): self.assertEqual(self.invoke("Stop", "0", "AUTHORITATIVE"), 0) self.assertEqual(self.exports, []) self.assertEqual(self.invoke("Stop", "0", background=True), 0) - self.assertEqual([trace["metadata"]["codex.turn_id"] for trace in self.exports], ["0", "1"]) - self.assertEqual([trace["metadata"]["darrow.work_item_id"] for trace in self.exports], - ["AUTHORITATIVE", "LATER"]) - self.assertEqual([trace["session_id"] for trace in self.exports], - ["session:attribution:0", "session:attribution:1"]) + self.assertEqual( + [trace["metadata"]["codex.turn_id"] for trace in self.exports], ["0", "1"] + ) + self.assertEqual( + [trace["metadata"]["darrow.work_item_id"] for trace in self.exports], + ["AUTHORITATIVE", "LATER"], + ) + self.assertEqual( + [trace["session_id"] for trace in self.exports], + ["session:attribution:0", "session:attribution:1"], + ) self.assertEqual(self.invoke("Stop", "0", "CHANGED"), 0) self.assertEqual(self.invoke("SessionStart", background=True), 0) self.assertEqual(len(self.exports), 2) - def test_session_end_seals_missing_stop_locally_then_prompt_drains(self): + def test_session_end_seals_missing_stop_locally_then_prompt_drains(self) -> None: rollout(self.path, 2) self.assertEqual(self.invoke("UserPromptSubmit", "0", "PROVISIONAL"), 0) self.assertEqual(self.invoke("Stop", "1", "LATER"), 0) self.assertEqual(self.invoke("SessionEnd", item="MUST-NOT-BECOME-FALLBACK"), 0) self.assertEqual(self.exports, []) self.assertEqual(self.invoke("UserPromptSubmit", "2", background=True), 0) - self.assertEqual([trace["metadata"]["darrow.work_item_id"] for trace in self.exports], - ["PROVISIONAL", "LATER"]) - self.assertEqual([trace["session_id"] for trace in self.exports], - ["session:attribution:0", "session:attribution:1"]) + self.assertEqual( + [trace["metadata"]["darrow.work_item_id"] for trace in self.exports], + ["PROVISIONAL", "LATER"], + ) + self.assertEqual( + [trace["session_id"] for trace in self.exports], + ["session:attribution:0", "session:attribution:1"], + ) - def test_new_session_recovers_prior_terminal_backlog(self): + def test_new_session_recovers_prior_terminal_backlog(self) -> None: rollout(self.path) self.assertEqual(self.invoke("UserPromptSubmit", "0", "OLD-SESSION"), 0) self.assertEqual(self.invoke("SessionEnd"), 0) self.path = self.root / "new-rollout.jsonl" rollout(self.path) self.session = "new-session" - self.path.write_text(self.path.read_text().replace('"id": "session"', '"id": "new-session"')) + self.path.write_text( + self.path.read_text().replace('"id": "session"', '"id": "new-session"') + ) self.assertEqual(self.invoke("SessionStart", background=True), 0) - self.assertEqual([trace["metadata"].get("darrow.work_item_id") for trace in self.exports], - ["OLD-SESSION"]) + self.assertEqual( + [trace["metadata"].get("darrow.work_item_id") for trace in self.exports], + ["OLD-SESSION"], + ) - def test_interrupt_resolves_active_turn_using_provisional_evidence(self): + def test_interrupt_resolves_active_turn_using_provisional_evidence(self) -> None: rollout(self.path) lines = self.path.read_text().splitlines() self.path.write_text("\n".join(lines[:-1]) + "\n") @@ -114,21 +192,43 @@ def test_interrupt_resolves_active_turn_using_provisional_evidence(self): self.assertEqual(self.invoke("Interrupt", "0", "IGNORED"), 0) self.assertEqual(self.exports, []) self.assertEqual(self.invoke("UserPromptSubmit", "1", background=True), 0) - self.assertEqual(self.exports[0]["metadata"]["darrow.work_item_id"], "BEFORE-INTERRUPT") + self.assertEqual( + self.exports[0]["metadata"]["darrow.work_item_id"], "BEFORE-INTERRUPT" + ) self.assertTrue(self.exports[0]["metadata"]["codex.aborted"]) - def test_preindexed_turn_is_not_a_final_capture_receipt(self): + def test_preindexed_turn_is_not_a_final_capture_receipt(self) -> None: rollout(self.path, 2) self.assertEqual(self.invoke("Stop", "1", "LATER"), 0) self.assertFalse(await_capture(self.path, "0", timeout=0.05)) - sent = [] - config = Config(enabled=True, work_item_id="EARLIER", public_key="pk-lifecycle", secret_key="sk-lifecycle") - def background(): + sent: list[dict[str, Any]] = [] + config = Config( + enabled=True, + work_item_id="EARLIER", + public_key="pk-lifecycle", + secret_key="sk-lifecycle", + ) + + def record(document: dict[str, Any], _config: Config) -> int: + sent.append(document) + return len(document["traces"]) + + def background() -> None: if await_capture(self.path, "0", timeout=3): - drain(self.path, config, cwd=str(self.root), exporter=lambda document, cfg: sent.append(document) or len(document["traces"])) + drain( + self.path, + config, + cwd=str(self.root), + exporter=record, + ) + with database(self.path) as connection: connection.execute("BEGIN IMMEDIATE") - foreground = threading.Thread(target=lambda: capture(self.path, config, str(self.root), "session", "0", self.plugin_data)) + foreground = threading.Thread( + target=lambda: capture( + self.path, config, str(self.root), "session", "0", self.plugin_data + ) + ) worker = threading.Thread(target=background) foreground.start() worker.start() @@ -143,7 +243,7 @@ def background(): self.assertEqual(len(sent), 1) self.assertEqual(len(sent[0]["traces"]), 2) - def test_terminal_receipt_does_not_wait_for_capture_transaction(self): + def test_terminal_receipt_does_not_wait_for_capture_transaction(self) -> None: rollout(self.path) with database(self.path) as connection: connection.execute("BEGIN IMMEDIATE") @@ -153,36 +253,56 @@ def test_terminal_receipt_does_not_wait_for_capture_transaction(self): connection.execute("ROLLBACK") self.assertEqual(self.exports, []) - def test_initial_capture_binding_serializes_terminal_context_without_waiting(self): + def test_initial_capture_binding_serializes_terminal_context_without_waiting( + self, + ) -> None: from dataclasses import replace + from darrow_observability_langfuse.capture import _put from darrow_observability_langfuse.lifecycle import record_terminal + rollout(self.path) config = Config(enabled=True, public_key="pk-origin", secret_key="sk-origin") started, finish = threading.Event(), threading.Event() - failures = [] - def paused_put(connection, key, value): - if key == "delivery_context": - started.set() - if not finish.wait(5): - raise TimeoutError("capture test was not released") - return _put(connection, key, value) - def foreground(): - try: - capture(self.path, config, str(self.root), "session", "0", self.plugin_data) - except BaseException as error: - failures.append(error) - with patch("darrow_observability_langfuse.capture._put", side_effect=paused_put): + failures: list[BaseException] = [] + + paused_put = partial(_paused_put, _put, started, finish) + foreground = partial( + _foreground_capture, + self.path, + config, + self.root, + self.plugin_data, + failures, + ) + + with patch( + "darrow_observability_langfuse.capture._put", side_effect=paused_put + ): worker = threading.Thread(target=foreground) worker.start() try: self.assertTrue(started.wait(2)) tick = time.monotonic() with self.assertRaisesRegex(ValueError, "delivery context"): - record_terminal(self.path, "session", "SessionEnd", None, - replace(config, public_key="pk-other"), self.plugin_data, cwd=str(self.root)) - record_terminal(self.path, "session", "SessionEnd", None, - config, self.plugin_data, cwd=str(self.root)) + record_terminal( + self.path, + "session", + "SessionEnd", + None, + replace(config, public_key="pk-other"), + self.plugin_data, + cwd=str(self.root), + ) + record_terminal( + self.path, + "session", + "SessionEnd", + None, + config, + self.plugin_data, + cwd=str(self.root), + ) self.assertLess(time.monotonic() - tick, 1) finally: finish.set() @@ -190,33 +310,58 @@ def foreground(): self.assertFalse(worker.is_alive()) self.assertEqual(failures, []) capture(self.path, config, str(self.root), "session", None, self.plugin_data) - self.assertEqual(drain(self.path, config, cwd=str(self.root), - exporter=lambda doc, cfg: len(doc["traces"])), 1) + self.assertEqual( + drain( + self.path, + config, + cwd=str(self.root), + exporter=lambda doc, cfg: len(doc["traces"]), + ), + 1, + ) - def test_watermark_does_not_seal_appended_live_turn(self): + def test_watermark_does_not_seal_appended_live_turn(self) -> None: rollout(self.path) self.assertEqual(self.invoke("SessionEnd"), 0) with self.path.open("a") as handle: - for payload in ({"type": "task_started", "turn_id": "1"}, {"type": "task_complete"}): - handle.write(json.dumps({"type": "event_msg", "payload": payload}) + "\n") + for payload in ( + {"type": "task_started", "turn_id": "1"}, + {"type": "task_complete"}, + ): + handle.write( + json.dumps({"type": "event_msg", "payload": payload}) + "\n" + ) self.assertEqual(self.invoke("SessionStart", background=True), 0) - self.assertEqual([trace["metadata"]["codex.turn_id"] for trace in self.exports], ["0"]) + self.assertEqual( + [trace["metadata"]["codex.turn_id"] for trace in self.exports], ["0"] + ) self.assertEqual(self.invoke("Stop", "1", "RESUMED"), 0) self.assertEqual(self.invoke("Stop", "1", background=True), 0) - self.assertEqual([trace["metadata"]["codex.turn_id"] for trace in self.exports], ["0", "1"]) + self.assertEqual( + [trace["metadata"]["codex.turn_id"] for trace in self.exports], ["0", "1"] + ) - def test_watermark_closes_active_turn_before_resumed_append(self): + def test_watermark_closes_active_turn_before_resumed_append(self) -> None: rollout(self.path) self.path.write_text("\n".join(self.path.read_text().splitlines()[:-1]) + "\n") self.assertEqual(self.invoke("UserPromptSubmit", "0", "SEALED"), 0) self.assertEqual(self.invoke("SessionEnd"), 0) with self.path.open("a") as handle: - for payload in ({"type": "task_started", "turn_id": "1"}, {"type": "task_complete"}): - handle.write(json.dumps({"type": "event_msg", "payload": payload}) + "\n") + for payload in ( + {"type": "task_started", "turn_id": "1"}, + {"type": "task_complete"}, + ): + handle.write( + json.dumps({"type": "event_msg", "payload": payload}) + "\n" + ) self.assertEqual(self.invoke("SessionStart", background=True), 0) - self.assertEqual([trace["metadata"]["codex.turn_id"] for trace in self.exports], ["0"]) + self.assertEqual( + [trace["metadata"]["codex.turn_id"] for trace in self.exports], ["0"] + ) - def test_terminal_missing_evidence_cannot_use_later_provisional_snapshot(self): + def test_terminal_missing_evidence_cannot_use_later_provisional_snapshot( + self, + ) -> None: rollout(self.path) self.assertEqual(self.invoke("SessionEnd"), 0) self.assertEqual(self.invoke("UserPromptSubmit", "0", "TOO-LATE"), 0) @@ -224,18 +369,23 @@ def test_terminal_missing_evidence_cannot_use_later_provisional_snapshot(self): self.assertNotIn("darrow.work_item_id", self.exports[0]["metadata"]) self.assertIsNone(self.exports[0]["session_id"]) - def test_terminal_receipt_survives_cancelled_materialization(self): + def test_terminal_receipt_survives_cancelled_materialization(self) -> None: rollout(self.path) self.assertEqual(self.invoke("UserPromptSubmit", "0", "DURABLE"), 0) self.assertEqual(self.invoke("SessionEnd"), 0) - with patch("darrow_observability_langfuse.capture._put", side_effect=KeyboardInterrupt): - with self.assertRaises(KeyboardInterrupt): - self.invoke("SessionStart", background=True) + with ( + patch( + "darrow_observability_langfuse.capture._put", + side_effect=KeyboardInterrupt, + ), + self.assertRaises(KeyboardInterrupt), + ): + self.invoke("SessionStart", background=True) self.assertEqual(delivery_rows(self.path), []) self.assertEqual(self.invoke("SessionStart", background=True), 0) self.assertEqual(self.exports[0]["metadata"]["darrow.work_item_id"], "DURABLE") - def test_repeated_late_stop_recognizes_an_already_sealed_receipt(self): + def test_repeated_late_stop_recognizes_an_already_sealed_receipt(self) -> None: rollout(self.path) self.assertEqual(self.invoke("SessionEnd"), 0) self.assertEqual(self.invoke("SessionStart", background=True), 0) @@ -244,7 +394,7 @@ def test_repeated_late_stop_recognizes_an_already_sealed_receipt(self): self.assertEqual(self.invoke("Stop", "0", background=True), 0) self.assertEqual(len(self.exports), 1) - def test_terminal_watermark_does_not_apply_to_replaced_rollout(self): + def test_terminal_watermark_does_not_apply_to_replaced_rollout(self) -> None: rollout(self.path) self.assertEqual(self.invoke("SessionEnd"), 0) replacement = self.root / "replacement.jsonl" @@ -253,12 +403,20 @@ def test_terminal_watermark_does_not_apply_to_replaced_rollout(self): self.assertEqual(self.invoke("SessionStart", background=True), 0) self.assertEqual(self.exports, []) - def test_final_stop_receipt_freezes_content_policy_while_waiting(self): + def test_final_stop_receipt_freezes_content_policy_while_waiting(self) -> None: from dataclasses import replace + rollout(self.path, 2) config = Config(enabled=True, capture_content=False, work_item_id="LATER") capture(self.path, config, str(self.root), "session", "1", self.plugin_data) - capture(self.path, replace(config, capture_content=True, work_item_id="EARLIER"), str(self.root), "session", "0", self.plugin_data) + capture( + self.path, + replace(config, capture_content=True, work_item_id="EARLIER"), + str(self.root), + "session", + "0", + self.plugin_data, + ) later = json.loads(delivery_rows(self.path)[1]["document"])["traces"][0] self.assertNotIn("input", later) diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/test_privacy.py b/plugins/capability/darrow-observability-langfuse/backend/tests/test_privacy.py index 72927acf..fe975fcc 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/tests/test_privacy.py +++ b/plugins/capability/darrow-observability-langfuse/backend/tests/test_privacy.py @@ -11,15 +11,64 @@ class PrivacyTest(unittest.TestCase): - def test_content_capture_redacts_credentials_and_sensitive_fields(self): + def test_content_capture_redacts_credentials_and_sensitive_fields(self) -> None: records = [ - {"timestamp": "2026-08-24T10:00:00Z", "type": "session_meta", "payload": {"id": "privacy-session"}}, - {"timestamp": "2026-08-24T10:00:01Z", "type": "event_msg", "payload": {"type": "task_started", "turn_id": "privacy-turn"}}, - {"timestamp": "2026-08-24T10:00:02Z", "type": "event_msg", "payload": {"type": "user_message", "message": "use sk-super-secret for the safe query"}}, - {"timestamp": "2026-08-24T10:00:03Z", "type": "response_item", "payload": {"type": "function_call", "name": "web", "call_id": "privacy-call", "arguments": "{\"authorization\":\"Bearer hidden\",\"query\":\"safe query\"}"}}, - {"timestamp": "2026-08-24T10:00:04Z", "type": "response_item", "payload": {"type": "function_call_output", "call_id": "privacy-call", "output": "pk-super-public accepted"}}, - {"timestamp": "2026-08-24T10:00:05Z", "type": "event_msg", "payload": {"type": "token_count", "info": {"last_token_usage": {"input_tokens": 8, "output_tokens": 2, "total_tokens": 10}}}}, - {"timestamp": "2026-08-24T10:00:06Z", "type": "event_msg", "payload": {"type": "task_complete", "turn_id": "privacy-turn"}}, + { + "timestamp": "2026-08-24T10:00:00Z", + "type": "session_meta", + "payload": {"id": "privacy-session"}, + }, + { + "timestamp": "2026-08-24T10:00:01Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "privacy-turn"}, + }, + { + "timestamp": "2026-08-24T10:00:02Z", + "type": "event_msg", + "payload": { + "type": "user_message", + "message": "use sk-super-secret for the safe query", + }, + }, + { + "timestamp": "2026-08-24T10:00:03Z", + "type": "response_item", + "payload": { + "type": "function_call", + "name": "web", + "call_id": "privacy-call", + "arguments": '{"authorization":"Bearer hidden","query":"safe query"}', + }, + }, + { + "timestamp": "2026-08-24T10:00:04Z", + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": "privacy-call", + "output": "pk-super-public accepted", + }, + }, + { + "timestamp": "2026-08-24T10:00:05Z", + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "last_token_usage": { + "input_tokens": 8, + "output_tokens": 2, + "total_tokens": 10, + } + }, + }, + }, + { + "timestamp": "2026-08-24T10:00:06Z", + "type": "event_msg", + "payload": {"type": "task_complete", "turn_id": "privacy-turn"}, + }, ] config = Config( enabled=True, @@ -29,7 +78,9 @@ def test_content_capture_redacts_credentials_and_sensitive_fields(self): ) with tempfile.TemporaryDirectory() as directory: rollout = Path(directory) / "rollout.jsonl" - rollout.write_text("\n".join(json.dumps(record) for record in records), encoding="utf-8") + rollout.write_text( + "\n".join(json.dumps(record) for record in records), encoding="utf-8" + ) serialized = json.dumps(trace_document(rollout, config, directory)) self.assertNotIn("sk-super-secret", serialized) @@ -38,17 +89,49 @@ def test_content_capture_redacts_credentials_and_sensitive_fields(self): self.assertIn("safe query", serialized) self.assertIn("[REDACTED]", serialized) - def test_capture_off_does_not_export_failed_tool_output(self): + def test_capture_off_does_not_export_failed_tool_output(self) -> None: records = [ - {"timestamp": "2026-08-24T10:00:00Z", "type": "session_meta", "payload": {"id": "privacy-session"}}, - {"timestamp": "2026-08-24T10:00:01Z", "type": "event_msg", "payload": {"type": "task_started", "turn_id": "privacy-turn"}}, - {"timestamp": "2026-08-24T10:00:02Z", "type": "response_item", "payload": {"type": "function_call", "name": "exec_command", "call_id": "privacy-call", "arguments": "{}"}}, - {"timestamp": "2026-08-24T10:00:03Z", "type": "event_msg", "payload": {"type": "exec_command_end", "call_id": "privacy-call", "status": "failed", "aggregated_output": "raw tool output SECRET"}}, - {"timestamp": "2026-08-24T10:00:04Z", "type": "event_msg", "payload": {"type": "task_complete", "turn_id": "privacy-turn"}}, + { + "timestamp": "2026-08-24T10:00:00Z", + "type": "session_meta", + "payload": {"id": "privacy-session"}, + }, + { + "timestamp": "2026-08-24T10:00:01Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "privacy-turn"}, + }, + { + "timestamp": "2026-08-24T10:00:02Z", + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "call_id": "privacy-call", + "arguments": "{}", + }, + }, + { + "timestamp": "2026-08-24T10:00:03Z", + "type": "event_msg", + "payload": { + "type": "exec_command_end", + "call_id": "privacy-call", + "status": "failed", + "aggregated_output": "raw tool output SECRET", + }, + }, + { + "timestamp": "2026-08-24T10:00:04Z", + "type": "event_msg", + "payload": {"type": "task_complete", "turn_id": "privacy-turn"}, + }, ] with tempfile.TemporaryDirectory() as directory: rollout = Path(directory) / "rollout.jsonl" - rollout.write_text("\n".join(json.dumps(record) for record in records), encoding="utf-8") + rollout.write_text( + "\n".join(json.dumps(record) for record in records), encoding="utf-8" + ) document = trace_document( rollout, Config(enabled=True, capture_content=False), @@ -62,12 +145,12 @@ def test_capture_off_does_not_export_failed_tool_output(self): self.assertNotIn("input", tool) self.assertEqual(tool["error"], "tool failed") - def test_capture_on_labels_nested_tool_invocations_with_parameters(self): + def test_capture_on_labels_nested_tool_invocations_with_parameters(self) -> None: source = "\n".join( ( "const values = await Promise.all([", ' tools.mcp__lean_ctx__ctx_shell({command:"git status"}),', - ' tools.exec_command({cmd:"lean-ctx -c \'jq --help\'"}),', + " tools.exec_command({cmd:\"lean-ctx -c 'jq --help'\"}),", ' tools.mcp__lean_ctx__ctx_read({path:"/repo/README.md",mode:"full"}),', "]);", ) @@ -128,7 +211,7 @@ def test_capture_on_labels_nested_tool_invocations_with_parameters(self): ) self.assertEqual(tool["input"], source) - def test_relative_transcript_path_is_rejected_before_resolution(self): + def test_relative_transcript_path_is_rejected_before_resolution(self) -> None: previous = Path.cwd() with tempfile.TemporaryDirectory() as directory: try: diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/test_properties.py b/plugins/capability/darrow-observability-langfuse/backend/tests/test_properties.py new file mode 100644 index 00000000..089820a4 --- /dev/null +++ b/plugins/capability/darrow-observability-langfuse/backend/tests/test_properties.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +from hypothesis import given, settings +from hypothesis import strategies as st + +from darrow_observability_langfuse.config import ( + Config, + infer_work_item_id_from_branch, +) +from darrow_observability_langfuse.rollout import trace_document + + +@settings(derandomize=True, max_examples=100) +@given( + kind=st.sampled_from( + ["feat", "fix", "refactor", "perf", "docs", "test", "chore", "build", "ci"] + ), + prefix=st.from_regex(r"[A-Z][A-Z0-9]{0,7}", fullmatch=True), + number=st.integers(min_value=1, max_value=999_999), +) +def test_branch_inference_preserves_the_exact_leading_token( + kind: str, prefix: str, number: int +) -> None: + token = f"{prefix}-{number}" + + assert infer_work_item_id_from_branch(f"{kind}/{token}-description") == token + assert infer_work_item_id_from_branch(f"{kind}/description-{token}") is None + + +@settings(derandomize=True, max_examples=25) +@given(secret=st.text(alphabet=st.characters(categories=("L", "N")), min_size=8)) +def test_configured_credentials_never_survive_content_capture(secret: str) -> None: + records = [ + {"type": "session_meta", "payload": {"id": "property-session"}}, + { + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "property-turn"}, + }, + { + "type": "event_msg", + "payload": {"type": "user_message", "message": f"before {secret} after"}, + }, + {"type": "event_msg", "payload": {"type": "task_complete"}}, + ] + with tempfile.TemporaryDirectory() as directory: + rollout = Path(directory) / "rollout.jsonl" + rollout.write_text( + "".join(f"{json.dumps(record)}\n" for record in records), encoding="utf-8" + ) + document = trace_document( + rollout, + Config(enabled=True, capture_content=True, secret_key=secret), + directory, + ) + + serialized = json.dumps(document) + assert secret not in serialized + assert "[REDACTED]" in serialized diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/test_quality_gate.py b/plugins/capability/darrow-observability-langfuse/backend/tests/test_quality_gate.py new file mode 100644 index 00000000..55bce6a0 --- /dev/null +++ b/plugins/capability/darrow-observability-langfuse/backend/tests/test_quality_gate.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import unittest +from typing import Any + +from scripts.verify_coverage import coverage_percentages, verify + + +def _report(*, lines: tuple[int, int], branches: tuple[int, int]) -> dict[str, Any]: + return { + "totals": { + "covered_lines": lines[0], + "num_statements": lines[1], + "covered_branches": branches[0], + "num_branches": branches[1], + } + } + + +class CoverageGateTests(unittest.TestCase): + def test_measures_lines_and_branches_separately(self) -> None: + report = _report(lines=(96, 100), branches=(95, 100)) + + self.assertEqual(coverage_percentages(report), (96.0, 95.0)) + self.assertEqual(verify(report, 95.0), (96.0, 95.0)) + + def test_rejects_high_line_coverage_with_low_branch_coverage(self) -> None: + report = _report(lines=(99, 100), branches=(94, 100)) + + with self.assertRaisesRegex(ValueError, "branch coverage 94.00%"): + verify(report, 95.0) + + def test_rejects_invalid_reports(self) -> None: + with self.assertRaisesRegex(ValueError, "no totals"): + coverage_percentages({}) + with self.assertRaisesRegex(ValueError, "invalid totals"): + coverage_percentages({"totals": {}}) diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/test_recovery_scope.py b/plugins/capability/darrow-observability-langfuse/backend/tests/test_recovery_scope.py index e9784092..67e889c8 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/tests/test_recovery_scope.py +++ b/plugins/capability/darrow-observability-langfuse/backend/tests/test_recovery_scope.py @@ -7,6 +7,7 @@ from dataclasses import replace from io import StringIO from pathlib import Path +from typing import Any from unittest.mock import patch from darrow_observability_langfuse.capture import database, delivery_rows @@ -15,10 +16,10 @@ class RecoveryScopeTest(unittest.TestCase): - def setUp(self): + def setUp(self) -> None: self.reset_fixture() - def reset_fixture(self): + def reset_fixture(self) -> None: self.directory = tempfile.TemporaryDirectory() self.addCleanup(self.directory.cleanup) self.root = Path(self.directory.name) @@ -27,30 +28,78 @@ def reset_fixture(self): project.mkdir() records = [ {"type": "session_meta", "payload": {"id": project.name}}, - {"type": "event_msg", "payload": {"type": "task_started", "turn_id": "0"}}, - {"type": "event_msg", "payload": {"type": "user_message", "message": "PRIVATE-" + project.name}}, + { + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "0"}, + }, + { + "type": "event_msg", + "payload": { + "type": "user_message", + "message": "PRIVATE-" + project.name, + }, + }, {"type": "event_msg", "payload": {"type": "task_complete"}}, ] - (project / "rollout.jsonl").write_text("".join(json.dumps(row) + "\n" for row in records)) - self.config = Config(enabled=True, strict=True, capture_content=True, - public_key="pk-A", secret_key="sk-A", base_url="https://A.example") - self.exports = [] + (project / "rollout.jsonl").write_text( + "".join(json.dumps(row) + "\n" for row in records) + ) + self.config = Config( + enabled=True, + strict=True, + capture_content=True, + public_key="pk-A", + secret_key="sk-A", + base_url="https://A.example", + ) + self.exports: list[tuple[dict[str, Any], Config]] = [] - def invoke(self, project, event, config=None, *, background=False, transcript=True, origin=None, home=None): - payload = {"hook_event_name": event, "session_id": project.name, - "turn_id": "0", "cwd": str(origin or project)} + def invoke( + self, + project: Path, + event: str, + config: Config | None = None, + *, + background: bool = False, + transcript: bool = True, + origin: Path | None = None, + home: Path | None = None, + ) -> int: + payload = { + "hook_event_name": event, + "session_id": project.name, + "turn_id": "0", + "cwd": str(origin or project), + } if transcript: payload["transcript_path"] = str(project / "rollout.jsonl") - def exporter(document, resolved): + + def exporter(document: dict[str, Any], resolved: Config) -> int: self.exports.append((document, resolved)) return len(document["traces"]) - with (patch.dict(os.environ, {"PLUGIN_DATA": str(self.root / "data"), "HOME": str(home or self.root / "home")}, clear=True), - patch("sys.stdin", StringIO(json.dumps(payload))), - patch("darrow_observability_langfuse.cli.load_config", return_value=config or self.config), - patch("darrow_observability_langfuse.cli.export_document", side_effect=exporter)): + + with ( + patch.dict( + os.environ, + { + "PLUGIN_DATA": str(self.root / "data"), + "HOME": str(home or self.root / "home"), + }, + clear=True, + ), + patch("sys.stdin", StringIO(json.dumps(payload))), + patch( + "darrow_observability_langfuse.cli.load_config", + return_value=config or self.config, + ), + patch( + "darrow_observability_langfuse.cli.export_document", + side_effect=exporter, + ), + ): return run(background=background) - def test_other_project_cannot_export_or_materialize_origin_backlog(self): + def test_other_project_cannot_export_or_materialize_origin_backlog(self) -> None: a, b = self.projects for capture_event in ("Stop", "SessionEnd", "Interrupt"): with self.subTest(capture_event=capture_event): @@ -59,62 +108,108 @@ def test_other_project_cannot_export_or_materialize_origin_backlog(self): a, b = self.projects self.assertEqual(self.invoke(a, capture_event), 0) before = delivery_rows(a / "rollout.jsonl") - foreign = replace(self.config, capture_content=False, public_key="pk-B", secret_key="sk-B", base_url="https://B.example") + foreign = replace( + self.config, + capture_content=False, + public_key="pk-B", + secret_key="sk-B", + base_url="https://B.example", + ) self.assertEqual(self.invoke(b, "Stop", foreign), 0) - self.assertEqual(self.invoke(b, "SessionStart", foreign, background=True), 0) - self.assertEqual([doc["traces"][0]["metadata"]["codex.thread_id"] for doc, _ in self.exports], ["B"]) + self.assertEqual( + self.invoke(b, "SessionStart", foreign, background=True), 0 + ) + self.assertEqual( + [ + doc["traces"][0]["metadata"]["codex.thread_id"] + for doc, _ in self.exports + ], + ["B"], + ) self.assertEqual(delivery_rows(a / "rollout.jsonl"), before) - self.assertEqual(self.invoke(a, "UserPromptSubmit", background=True, transcript=False), 0) + self.assertEqual( + self.invoke( + a, "UserPromptSubmit", background=True, transcript=False + ), + 0, + ) self.assertEqual(self.exports[-1][0]["traces"][0]["input"], "PRIVATE-A") self.assertEqual(self.exports[-1][1].base_url, self.config.base_url) self.assertEqual(self.invoke(a, "SessionStart", background=True), 0) self.assertEqual(len(self.exports), 2) - def test_direct_hooks_reject_changed_project_destination_or_config_home(self): + def test_direct_hooks_reject_changed_project_destination_or_config_home( + self, + ) -> None: a, b = self.projects self.assertEqual(self.invoke(a, "Stop"), 0) before = delivery_rows(a / "rollout.jsonl") - variants = [ + variants: list[dict[str, Any]] = [ {"origin": b}, {"home": self.root / "other-home"}, {"config": replace(self.config, public_key="pk-B")}, {"config": replace(self.config, base_url="https://B.example")}, ] for variant in variants: - for event, background in (("Stop", False), ("Stop", True), ("SessionStart", True)): + for event, background in ( + ("Stop", False), + ("Stop", True), + ("SessionStart", True), + ): with self.subTest(variant=variant, event=event, background=background): - self.assertEqual(self.invoke(a, event, background=background, **variant), 1) + self.assertEqual( + self.invoke(a, event, background=background, **variant), 1 + ) self.assertEqual(self.exports, []) self.assertEqual(delivery_rows(a / "rollout.jsonl"), before) - rotated = replace(self.config, secret_key="sk-rotated", base_url="https://A.example/") + rotated = replace( + self.config, secret_key="sk-rotated", base_url="https://A.example/" + ) self.assertEqual(self.invoke(a, "Stop", rotated, background=True), 0) self.assertEqual(len(self.exports), 1) - def test_same_destination_does_not_authorize_another_project_registry(self): + def test_same_destination_does_not_authorize_another_project_registry(self) -> None: a, b = self.projects self.assertEqual(self.invoke(a, "Stop"), 0) - self.assertEqual(self.invoke(b, "SessionStart", background=True, transcript=False), 0) + self.assertEqual( + self.invoke(b, "SessionStart", background=True, transcript=False), 0 + ) self.assertEqual(self.exports, []) self.assertEqual(delivery_rows(a / "rollout.jsonl")[0]["state"], "pending") - def test_changed_context_terminal_hook_cannot_poison_original_recovery(self): + def test_changed_context_terminal_hook_cannot_poison_original_recovery( + self, + ) -> None: for first in ("Stop", "SessionEnd", "Interrupt"): for later in ("SessionEnd", "Interrupt"): with self.subTest(first=first, later=later): self.reset_fixture() a, b = self.projects self.assertEqual(self.invoke(a, first), 0) - before = {path: path.read_bytes() for path in self.root.rglob("*.json")} - for variant in ({"origin": b}, {"home": self.root / "other-home"}, - {"config": replace(self.config, base_url="https://B.example")}, - {"config": replace(self.config, public_key="pk-B")}): + before = { + path: path.read_bytes() for path in self.root.rglob("*.json") + } + for variant in ( + {"origin": b}, + {"home": self.root / "other-home"}, + {"config": replace(self.config, base_url="https://B.example")}, + {"config": replace(self.config, public_key="pk-B")}, + ): self.assertEqual(self.invoke(a, later, **variant), 1) - self.assertEqual({path: path.read_bytes() for path in self.root.rglob("*.json")}, before) + self.assertEqual( + { + path: path.read_bytes() + for path in self.root.rglob("*.json") + }, + before, + ) self.assertEqual(self.invoke(a, "SessionStart", background=True), 0) self.assertEqual(len(self.exports), 1) - self.assertEqual(self.exports[0][0]["traces"][0]["input"], "PRIVATE-A") + self.assertEqual( + self.exports[0][0]["traces"][0]["input"], "PRIVATE-A" + ) - def test_unbound_legacy_database_is_not_adopted(self): + def test_unbound_legacy_database_is_not_adopted(self) -> None: a, _ = self.projects self.assertEqual(self.invoke(a, "Stop"), 0) with database(a / "rollout.jsonl") as connection: @@ -124,18 +219,20 @@ def test_unbound_legacy_database_is_not_adopted(self): self.assertEqual(self.exports, []) self.assertEqual(delivery_rows(a / "rollout.jsonl")[0]["state"], "pending") - def test_unbound_legacy_terminal_receipt_is_not_adopted(self): + def test_unbound_legacy_terminal_receipt_is_not_adopted(self) -> None: a, _ = self.projects self.assertEqual(self.invoke(a, "SessionEnd"), 0) for path in self.root.rglob("*.json"): value = json.loads(path.read_text()) value.pop("delivery_context", None) path.write_text(json.dumps(value)) - self.assertEqual(self.invoke(a, "SessionStart", background=True, transcript=False), 0) + self.assertEqual( + self.invoke(a, "SessionStart", background=True, transcript=False), 0 + ) self.assertEqual(self.invoke(a, "SessionStart", background=True), 1) self.assertEqual(self.exports, []) - def test_binding_persists_no_credentials_or_raw_endpoint(self): + def test_binding_persists_no_credentials_or_raw_endpoint(self) -> None: a, _ = self.projects self.assertEqual(self.invoke(a, "Stop"), 0) self.assertEqual(self.invoke(a, "SessionEnd"), 0) diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/test_sidecar.py b/plugins/capability/darrow-observability-langfuse/backend/tests/test_sidecar.py index 7945bd6c..2f042e00 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/tests/test_sidecar.py +++ b/plugins/capability/darrow-observability-langfuse/backend/tests/test_sidecar.py @@ -19,17 +19,30 @@ class SidecarTest(unittest.TestCase): - def test_concurrent_provisional_snapshots_do_not_lose_updates(self): - snapshot = {"work_item_id": None, "source": "none", "branch": None, "head": None} + def test_concurrent_provisional_snapshots_do_not_lose_updates(self) -> None: + snapshot = { + "work_item_id": None, + "source": "none", + "branch": None, + "head": None, + } with tempfile.TemporaryDirectory() as directory: plugin_data = Path(directory) with ThreadPoolExecutor(max_workers=8) as executor: - list(executor.map(lambda index: record_provisional_attribution_snapshot( - plugin_data, "session", str(index), snapshot), range(32))) - self.assertEqual(set(load_provisional_attribution_snapshots(plugin_data, "session")), - {str(index) for index in range(32)}) + list( + executor.map( + lambda index: record_provisional_attribution_snapshot( + plugin_data, "session", str(index), snapshot + ), + range(32), + ) + ) + self.assertEqual( + set(load_provisional_attribution_snapshots(plugin_data, "session")), + {str(index) for index in range(32)}, + ) - def test_provisional_snapshot_is_private_immutable_and_discardable(self): + def test_provisional_snapshot_is_private_immutable_and_discardable(self) -> None: snapshot = { "work_item_id": "issue-45", "source": "git_branch", @@ -73,7 +86,7 @@ def test_provisional_snapshot_is_private_immutable_and_discardable(self): list((plugin_data / "attribution-snapshots").glob("*.json")), [] ) - def test_semantically_invalid_attribution_snapshots_are_refused(self): + def test_semantically_invalid_attribution_snapshots_are_refused(self) -> None: valid = { "work_item_id": "issue-45", "source": "git_branch", @@ -117,9 +130,7 @@ def test_semantically_invalid_attribution_snapshots_are_refused(self): { "version": 1, "uploaded_turn_ids": [], - "attribution_snapshots": { - "turn-1": {**valid, "branch": None} - }, + "attribution_snapshots": {"turn-1": {**valid, "branch": None}}, }, ) with tempfile.TemporaryDirectory() as directory: @@ -138,7 +149,9 @@ def test_semantically_invalid_attribution_snapshots_are_refused(self): ) self.assertEqual(load_attribution_snapshots(rollout), {}) - def test_attribution_snapshot_survives_export_marking_and_is_immutable(self): + def test_attribution_snapshot_survives_export_marking_and_is_immutable( + self, + ) -> None: snapshot = { "work_item_id": "issue-45", "source": "git_branch", @@ -177,7 +190,7 @@ def test_attribution_snapshot_survives_export_marking_and_is_immutable(self): 0o600, ) - def test_completed_turn_is_not_exported_twice(self): + def test_completed_turn_is_not_exported_twice(self) -> None: document = { "status": "dry-run", "traces": [ @@ -196,7 +209,9 @@ def test_completed_turn_is_not_exported_twice(self): self.assertEqual(pending_document(document, rollout)["traces"], []) sidecar = Path(f"{rollout}.darrow-langfuse") - self.assertEqual(json.loads(sidecar.read_text())["uploaded_turn_ids"], ["turn-1"]) + self.assertEqual( + json.loads(sidecar.read_text())["uploaded_turn_ids"], ["turn-1"] + ) self.assertEqual(os.stat(sidecar).st_mode & 0o777, 0o600) diff --git a/plugins/capability/darrow-observability-langfuse/backend/uv.lock b/plugins/capability/darrow-observability-langfuse/backend/uv.lock index e402c8d2..41978bd9 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/uv.lock +++ b/plugins/capability/darrow-observability-langfuse/backend/uv.lock @@ -137,6 +137,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/62/a8b4dd53308a4b1571e6bdd77ee562c9b1c1742db9886c0aef2f69463067/coverage-7.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f12a9e27ca7b65e40a8475d27899b2d45064d9020e6a89148939e01987b5853", size = 223178, upload-time = "2026-09-13T19:08:21.805Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7a/b9325b8d486a5c05fd4a9eb1cd9c9f697c361ddeda0e73567d5c2d8959f6/coverage-7.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f590d46c30d9e4c1fda3efefe5443f4c2f6a4192c5ca2ba403653e9ebadf097", size = 223701, upload-time = "2026-09-13T19:08:23.391Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ee/3ab3ac6e9e7165a623695b73a83d1bfbdf5069681a68a012cb5abb096060/coverage-7.16.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3df82f0a3cef4e1bcfc799436056f0b978dda319d0bfd4460c6e479b2802d98a", size = 250434, upload-time = "2026-09-13T19:08:24.948Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a9/31ffc231e9879876f611cf1ac7622cf092d76762c07ca64dce47b29a200b/coverage-7.16.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb462d59146656e278d1e8ed913ce374d0ba68a4e081acde1867f6d3377fc881", size = 252264, upload-time = "2026-09-13T19:08:26.734Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f4/4fb4f6b16de07dbae3d3a111ae28d32ad657ce6ff4d364c49c10962daad5/coverage-7.16.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7db888dd0a1df1a653cae7f99d4047430d2187a3626eda51bc847b0fd6b9b43", size = 254127, upload-time = "2026-09-13T19:08:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3d/048e88eb610f44b7542dac10fa45bb2ffad5fbfcdab0f03289a843da934b/coverage-7.16.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:38a7e16f061504ac2b45370bf5bf97e8250d8d3f25e37385bae884978166554b", size = 256043, upload-time = "2026-09-13T19:08:30.019Z" }, + { url = "https://files.pythonhosted.org/packages/97/59/d27b113c80b04c7fc64c69457132648e1d287b83a1de41189a504b50b52b/coverage-7.16.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b26b55b18e1a53e1a159dd743728c4ddbbef28ba19000a91aaff5ce023197ec", size = 251092, upload-time = "2026-09-13T19:08:31.57Z" }, + { url = "https://files.pythonhosted.org/packages/45/42/b7eafaceff08dc1dcbfc924fa9233031fd00c8b8287cc4a1a0dcdedcb0f9/coverage-7.16.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fdb2f528b50953e29d22033b3256c396a700193c6e45b2490222ef9c333cbbf9", size = 252170, upload-time = "2026-09-13T19:08:33.191Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8f/57dcc5360aa6d8d24370c12fe855f6ebf5fbf2718c154fb9fdec7161de07/coverage-7.16.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3284754371dc78592aa3ae4d661d30ee20e02b2b6b0de3590a181a936d0d3b38", size = 250173, upload-time = "2026-09-13T19:08:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/379a8c7ac0f3063769dc43fd2475e9fc9b2722cb88eabe71f4d862660c93/coverage-7.16.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:802d1246c540e07486d4ee1adfa19a797e4b33529ffc371dc140644e8f27da0a", size = 253986, upload-time = "2026-09-13T19:08:36.737Z" }, + { url = "https://files.pythonhosted.org/packages/45/25/70218d2a6077730843b75ee2525fa64a750283d71cc4c063dc0b75ba6979/coverage-7.16.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6fc735d6fe6d57f803e7ba021be4dde48e43e6aff94e6954350555d5332b0594", size = 250444, upload-time = "2026-09-13T19:08:38.434Z" }, + { url = "https://files.pythonhosted.org/packages/58/ac/6c41c441baa6c8a1fed5615e4e1e7d0ca649b33b472ee28591a92d70a1ff/coverage-7.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed5ade1bb18f62edace1bd198c66f9d4c75a8385d5fd24e87d917ea1a5958773", size = 251046, upload-time = "2026-09-13T19:08:40.133Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/01166d6f8aa42acd148b4c2c88173d60eefdd0270ea2bcbba79be35bc193/coverage-7.16.1-cp310-cp310-win32.whl", hash = "sha256:0c309096926b119543dc16438a11ef4c80783d2f4e59ff94f7f462651a944cdc", size = 225242, upload-time = "2026-09-13T19:08:41.672Z" }, + { url = "https://files.pythonhosted.org/packages/d2/28/a76060478919becf0fbea2df2053a6e1a7195f11edf8dfd51c58c1636b43/coverage-7.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:7562f8067ed9360e8b9739e5703403a7686dd1b36bf0f89fc538047c54cdea90", size = 225868, upload-time = "2026-09-13T19:08:43.22Z" }, + { url = "https://files.pythonhosted.org/packages/af/3a/d09495dfd5191b5593852bbe2f037afae768157443378d066d9ecea69a49/coverage-7.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72e013665e25cf9d44779f01f340af26319756f9a76822b7c94ce6b1d93813da", size = 223307, upload-time = "2026-09-13T19:08:44.914Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/310196a80258e28ebf6e5aa814a5a6ffbb18c03cf3c0c0c04191ed893e88/coverage-7.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb05c0ff98b56ba6969adf35556bc43bcb8d094df8bc9cb403acff53460c4e07", size = 223817, upload-time = "2026-09-13T19:08:46.738Z" }, + { url = "https://files.pythonhosted.org/packages/73/29/ed0fda1fcd440cdfc07e72a07f3c9c3b43f65a6b3d53bff2cba9b9de50e8/coverage-7.16.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0ececb32090e3fbb03e0d352b973a0485879b4de6c58daf47227b9988b99e5", size = 254222, upload-time = "2026-09-13T19:08:48.358Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4e/5e2507fbd71ec1047620978f39568b0d2ed6c4468d08495a0c24f5677f65/coverage-7.16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f0ba3892d81aacf36996c52f16bca04e39af31a6c5de930b7688ab617f4a6475", size = 256134, upload-time = "2026-09-13T19:08:49.926Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b1/a9f97846554bc240473656f9c3874591105f29c08eccf4282daadc6bc281/coverage-7.16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6410b75fe07d5271eaa95fc24bd0a9177ed588d9d1c10c0cf67829adb8f0567", size = 258240, upload-time = "2026-09-13T19:08:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/2b1ab208d06719394851cdd7dc322157870a93b178410b1bda6cac7755f9/coverage-7.16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1039cb2de093225d597109342ce1675626bd127565e80b3044f4eca07c15b2e", size = 260202, upload-time = "2026-09-13T19:08:53.204Z" }, + { url = "https://files.pythonhosted.org/packages/af/14/ff4db31d6e3126d5b72aab1e868d35ec94e1b71dee46f5c3b54cb1b2438e/coverage-7.16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cf047bc39fde5425be2628666d0f435ed8817859111c3aacc84b32d858069f5d", size = 254305, upload-time = "2026-09-13T19:08:54.833Z" }, + { url = "https://files.pythonhosted.org/packages/ce/27/c28faa4818f61c49ca3bfb4a77e1c191e79a40447504b89af0bd859cd6fc/coverage-7.16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c05913d0d5badf7ac83200f35dcf9514cce5df16a7cc89e7d1d7fff0461813b", size = 255935, upload-time = "2026-09-13T19:08:56.453Z" }, + { url = "https://files.pythonhosted.org/packages/46/5a/42e64e5b716048cae33e2e15f8c6fe04a927467056c533b8e88900da895e/coverage-7.16.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4027bf6d7bc0a16df058ce913b69f10c5687f8e1ca668f08caa659ce101744bf", size = 253995, upload-time = "2026-09-13T19:08:58.159Z" }, + { url = "https://files.pythonhosted.org/packages/84/e5/860287acd5a1e29acd337f302b476b54148203590091b1a1555b2e914b50/coverage-7.16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4f48b345f831eaf4402ab6333c2dc3e2e2b5bc7b9c1b8fe12680dee3f0538f01", size = 257767, upload-time = "2026-09-13T19:08:59.958Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c5/4b68006da567b4b413352f891776c35776b3557a0479a28187ab57c21659/coverage-7.16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8643baeb590726c558b2faed6cd59b0917480f9367fcc692026f1a86d824fd08", size = 253715, upload-time = "2026-09-13T19:09:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0e/b2d0c47cfbf11770e197f1d0f11a92864eb29fd7eaca45bc9915573d9725/coverage-7.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d06dcc420b570bf683cdb647cc8fe62b672d9e429ef711c3cbbb7a6880ca1572", size = 254624, upload-time = "2026-09-13T19:09:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/2e/14/3bab8821b2b942155578fc34e88e21b0670d922f039ab23651f6035ba7f7/coverage-7.16.1-cp311-cp311-win32.whl", hash = "sha256:946f58aa59b08bcd6afcc6a7bd0ff54ed5eee844f32ad69fe6814836d15856a2", size = 225404, upload-time = "2026-09-13T19:09:05.174Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ce/4f4ce667a97d1c538b46a79b89161395ad2beffc248b6c782afc9f521927/coverage-7.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:684c7ee9b4c04358fe6ac8b517ab51ec35fcd79d08ff0f105dd8bcd96885bbb7", size = 225879, upload-time = "2026-09-13T19:09:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/26/17/f3dce5e47351ab34522dd037fd64b111742958e24861bdd2820971bc1a26/coverage-7.16.1-cp311-cp311-win_arm64.whl", hash = "sha256:1b24f79e25bcf6c73931aeca7a3dfc7595c0cb5e9364aba3fdf387a3de4b1c22", size = 225428, upload-time = "2026-09-13T19:09:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f7/7cd4c9f2a3b7574414222ec425d5eee21cc690d867a013315e3be8ba185c/coverage-7.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b7f2c26ce6ce0b1e0ca0d5fae96ea510e3a2e78b7207f06e76b7f2c87fa3d0af", size = 223475, upload-time = "2026-09-13T19:09:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f2/9ac65f9cedd43f91e2d3657c11f13aef82aebae89b2243dc9e44fe58a740/coverage-7.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070acb9da788dff743a4d36fc015feee12d68f0349959017542017c79f59c21c", size = 223845, upload-time = "2026-09-13T19:09:11.988Z" }, + { url = "https://files.pythonhosted.org/packages/62/4d/f13db452d4367fe1122abfd98ae330596f65a3b40498f8e1a5811f68f123/coverage-7.16.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e366587b370bc9b8b51b7b7272c610c56db5d5b4795b9e4a29d28ff2f440f809", size = 255341, upload-time = "2026-09-13T19:09:13.708Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6b/85ed86e82835a96ddfbe7705c387c28ce1cbbecd1b6d606f5ddfeeb712df/coverage-7.16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e82e10b9d290f60b63459cfb245a841aec347603997206296b93881463a93dcf", size = 258078, upload-time = "2026-09-13T19:09:15.368Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d2/f66945853d850b9c05f4e012e37396f1f31d0cd40d062394b229c22e7b56/coverage-7.16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d73bb1f85c4150ac208fb0755beb04b2e44897bad81414de9380f98dd74729f", size = 259191, upload-time = "2026-09-13T19:09:17.046Z" }, + { url = "https://files.pythonhosted.org/packages/77/a1/7b907abe62461f289035c7ac60ab3e6334efb8c425151aac4abfa0c97820/coverage-7.16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3397b9032553d281ad6a9253b12675b65e0cc8cd7a3b0633cf48872c9eb13360", size = 261452, upload-time = "2026-09-13T19:09:18.756Z" }, + { url = "https://files.pythonhosted.org/packages/51/e6/e26f4d6b1069a2a2fee3238a132f85c5b4c1dc25aebd88e015451ff0bd68/coverage-7.16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77890395cf37026a5907d3ad32376aa51f41c0f163b7477fdbd4f94966cc1d08", size = 255698, upload-time = "2026-09-13T19:09:20.786Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ec/99db7450e813050ebce300e31bbd4f997c76a8c6e3a6d168c5dffc104109/coverage-7.16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b0944dc3bee3091039bf970d73caaf930c906013128a421bdc132e797494d941", size = 257111, upload-time = "2026-09-13T19:09:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9d/500df9d3cd8c541ac84b5e0bcab00646be75654e05aa34e8410ec851282d/coverage-7.16.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b89d22a89d5bc05dd95b64e08295b8394aa96dc88e08f8ba210c9ebfebbe0489", size = 255258, upload-time = "2026-09-13T19:09:24.429Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/53318d96da332bb4946f8ac646fa19fd37fecbd0c49144735c716ac69bf0/coverage-7.16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:550a2a1faf7559f13d5344f12d1eb886ad87955155d7dfab2a3fe5c8ec8fe776", size = 259326, upload-time = "2026-09-13T19:09:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ce/abc0462b2e6ae96ae22197d2bc30fe33b6b4e52937e782745436ceb2a761/coverage-7.16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:55eb268e5b81aefac759766c9162625b06c1bedb7b77d936225bafc4f038a6f6", size = 254827, upload-time = "2026-09-13T19:09:28.191Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e1/0a04eedaaf19196b51f0180968134228c7646e469ed29914e48690a7cf3e/coverage-7.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65a8fc80898c9ce59f04349fe8b4849b1f9787f14e52ead990e5f849ff4727a0", size = 256698, upload-time = "2026-09-13T19:09:29.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/1d/d441a55cf22ce9d8e9e34814806c47441ab844bd534e0f4b64e1c6ae8bd1/coverage-7.16.1-cp312-cp312-win32.whl", hash = "sha256:528a61be40977c340cf201d23b69bd6a6bab507da60e9dbda85f8b30e935d70d", size = 225541, upload-time = "2026-09-13T19:09:31.752Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/ec3d032375735dd331477caa051419678079ff90fcb53d0284a6c2bfb757/coverage-7.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:d0f02c633630e2b74522108ee95a84ad6e1204a8016a6cca5297f335ea27147e", size = 226075, upload-time = "2026-09-13T19:09:33.556Z" }, + { url = "https://files.pythonhosted.org/packages/94/00/90e9f5c4434878494306b9c0ee8068ebbd829483737d8cefccc58884d728/coverage-7.16.1-cp312-cp312-win_arm64.whl", hash = "sha256:2959978f9d1d20a2c0c15d0a68baaeccf615ac1aa214cf4a05a10d6f568926c8", size = 225461, upload-time = "2026-09-13T19:09:35.48Z" }, + { url = "https://files.pythonhosted.org/packages/aa/74/c08c0c4dc9fa6bcd1d90728a63660aa1b17b488a806948598456c48f75d1/coverage-7.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ee5465db6e9152a7d09f3215309326878c6aa3ac509195a369f9d264ff4bfbd9", size = 223501, upload-time = "2026-09-13T19:09:37.276Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c8/784986d326663285258a8e39835c46fb730cc85284f0dbcd82078586dd22/coverage-7.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b8256f8b525ba233d2e4cdcdce0d6673c66fc9bf70df1fd5e67c54a74e2d245", size = 223876, upload-time = "2026-09-13T19:09:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/15/76/73fb792928872bbb07e553f920ff55c65ee962c469265feb5d1d4ae5f97a/coverage-7.16.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d57cc400275b9a2892e905fc893f732b21ddb95271bf96406c88e2f6367848b5", size = 254863, upload-time = "2026-09-13T19:09:41.326Z" }, + { url = "https://files.pythonhosted.org/packages/ca/8d/1fd78899513244b065ccecdd1cfc6aa8b8f1bdee796d8c4f2aa8e8e5397d/coverage-7.16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b3fd0f3435ebb7a7183b32a6062a8b755f08242ced1f3f22761d30b56b3c2a5", size = 257460, upload-time = "2026-09-13T19:09:43.086Z" }, + { url = "https://files.pythonhosted.org/packages/47/1c/b23ddfcb7ef9bd7b3fdb7be9a5dccf9925ae88e556df7aa5b655283c78f2/coverage-7.16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5eb1762e7eb5fad34ef913e8107c7788a66f19d328e598ce95bf7217f9e5c8f", size = 258697, upload-time = "2026-09-13T19:09:45.161Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c9/19d0c6ca35778a7e9415c30c46a0c64c9d4374219d004a35563132296896/coverage-7.16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46cd3a73e9140410de62cceb66214bce0e08fb3922b9176fbfc1522fec151b41", size = 260827, upload-time = "2026-09-13T19:09:47.061Z" }, + { url = "https://files.pythonhosted.org/packages/97/fc/5862f7344382c62b85df43d483e579168cc062e007f54d895dfa51fe3e7d/coverage-7.16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d1ba5142d68dd2cb775cbd0ac8601819298152047803c8efe4eec6d7d7aa7878", size = 255038, upload-time = "2026-09-13T19:09:48.945Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9f/5c26583199a68df8d15124b4590520903f8eb7cef17db293fea712bb0783/coverage-7.16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c389c6f9d1d518e1249ddcb8a7f158135644ce2c508fa6cc17b680777dad5bf2", size = 256827, upload-time = "2026-09-13T19:09:50.738Z" }, + { url = "https://files.pythonhosted.org/packages/95/13/605198bff079b107336710f28a797312ab132587168600d70a290e7ecd2e/coverage-7.16.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cdc57746c7ac0ea063351b4d651c3bb4dd4fd35e64dbb8e90c10e14eb03c4080", size = 254794, upload-time = "2026-09-13T19:09:52.577Z" }, + { url = "https://files.pythonhosted.org/packages/43/b7/0bbb32dc5ccdac766a13763fdfbffd7d73fc80349a69230c46c6b71e7508/coverage-7.16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5597180ed7670cc94c04c65347418a467d3a43d5f0cf52fcac647f5425f42037", size = 258947, upload-time = "2026-09-13T19:09:54.372Z" }, + { url = "https://files.pythonhosted.org/packages/13/bd/65c31ddd43ff4e61b63721b3dad17cca412dba6e2ce89f92abdf75734339/coverage-7.16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9647a0ac46255b8fef59a433a2161f03e5483f3a35e1cbd9dfe4600baff0c6b", size = 254613, upload-time = "2026-09-13T19:09:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/25/20/d278115f2ba522b3e3128c6852be265f4e0df7202b2effca4db96cf0217d/coverage-7.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e306e98186b9cd109121f3583aeb7978797ad21d948f22944c5c08845cd554d0", size = 256388, upload-time = "2026-09-13T19:09:58.095Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ca/d43aa396fb3a2f71c9411b99d926475f5acaf5c124f605c592865c80c8d5/coverage-7.16.1-cp313-cp313-win32.whl", hash = "sha256:48a78a66fcce49d7f6156524bf979c0ac633d584199717c68c6ffa949fc14e6a", size = 225550, upload-time = "2026-09-13T19:09:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/df2f6114f6a17cffe94721bd1d298f77f86aad493717fa5bc03cb291a1a6/coverage-7.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af03247d598a353bbbbe1b925deb735276e4d845e7197c4073dc89352b236fa", size = 226091, upload-time = "2026-09-13T19:10:02.331Z" }, + { url = "https://files.pythonhosted.org/packages/ee/31/6f90d8aab72a112492bd50e3b5485b53b772b1f5fa70da0a620e723caae6/coverage-7.16.1-cp313-cp313-win_arm64.whl", hash = "sha256:166adae25b05b04c9a84135912066d9c97482115af38df1a419a38aacc6b6f5d", size = 225481, upload-time = "2026-09-13T19:10:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "darrow-observability-langfuse" version = "0.1.0" @@ -146,12 +228,32 @@ dependencies = [ { name = "opentelemetry-exporter-otlp-proto-http" }, ] +[package.dev-dependencies] +dev = [ + { name = "coverage", extra = ["toml"] }, + { name = "hypothesis" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "types-protobuf" }, +] + [package.metadata] requires-dist = [ { name = "langfuse", specifier = ">=4.0,<5" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.36,<2" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "coverage", extras = ["toml"], specifier = ">=7.10,<8" }, + { name = "hypothesis", specifier = ">=6.138,<7" }, + { name = "mypy", specifier = ">=1.18,<2" }, + { name = "pytest", specifier = ">=8.4,<9" }, + { name = "ruff", specifier = ">=0.12,<1" }, + { name = "types-protobuf", specifier = ">=6.32,<7" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -213,6 +315,83 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "hypothesis" +version = "6.168.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/ce/c0946bebffb99b62426a6a7643d4272cc6c5cf777a488b3b4d0ee724e960/hypothesis-6.168.0.tar.gz", hash = "sha256:72af51087b7b5ab21c49f0d502f803c20897678652835596bd2a8b169a39135e", size = 510805, upload-time = "2026-09-08T18:48:36.072Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/f8/8b2cc9ae7b439538f6f2d32a92892340b6343a6b04d171c516666901dedc/hypothesis-6.168.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:47b89491ff02e3ae9b302c440457938e87b47a45b9a1d98ff5575b6910d779e2", size = 791358, upload-time = "2026-09-08T18:47:37.076Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f4/4d7d897310cde5085779fb96feadb8529d98cb8e51ed7b24f7da9b6c6bdc/hypothesis-6.168.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:1f4cd0ff11bd470a1a846296ed5fe55e84214194850370994fd1370fe73d3099", size = 787081, upload-time = "2026-09-08T18:47:16.227Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/9d52066d363faba7f3ac20ee60a3c696a475feb2c477d235d0d649d41cc1/hypothesis-6.168.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:732ae5d47482f99d8028cca096729625f05690a83f5e7ce31466e266155792f4", size = 1123850, upload-time = "2026-09-08T18:48:11.504Z" }, + { url = "https://files.pythonhosted.org/packages/50/cf/aa46d76fa7df43caf2c372e394fda84ce1dc08421814f8674a6b9295e2ea/hypothesis-6.168.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2085ee74ac3ab6b70e2f7ffae9b4cb74c246da2f574b2de81a0818a8a30f659f", size = 1147685, upload-time = "2026-09-08T18:46:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/84/c2/78ed8c8d5aa37e4baae2a4b3e29687ee3d9b7f1a5e56ea5ea5ec7ec71ecb/hypothesis-6.168.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1894782fae5d9a7bb44e6dcf848ccb09ccb5babab48d8b5c31a0a7fc025b82a1", size = 1149294, upload-time = "2026-09-08T18:47:04.757Z" }, + { url = "https://files.pythonhosted.org/packages/78/7f/d57440f19e9de70e85359cf179ce786f309ee17609bd3c5a0113272875c0/hypothesis-6.168.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecf0ab13cef899efb816ffdd7963e0679f372520884ce06756c7642f3df94213", size = 1169729, upload-time = "2026-09-08T18:47:44.056Z" }, + { url = "https://files.pythonhosted.org/packages/a1/86/dc74410a186990bb22c2a3eea0e77804f2eb0f300860b46d7d8948073674/hypothesis-6.168.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:3f6dcf66270278d078bed01b401f47db4e26456cd909d8e23c6b9366a6c0b131", size = 1129182, upload-time = "2026-09-08T18:46:33.382Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5e/be048fc4f6dac831625e155bdf11e4233caf46bb54030391d6fba8e19449/hypothesis-6.168.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bfef4d46dbf1704a7b8fa3a78778651a2cb18870ca0a70da19c381646822b149", size = 1160180, upload-time = "2026-09-08T18:47:26.459Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4a/15a34498a5f08720fbbdbbe4668a8050fe4e17c16c9eeb6f56a017f2fa6b/hypothesis-6.168.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1d1aa5b3484e329295d88488a5ba06243909e65c2ab616513c2d36721de4ed1d", size = 1299711, upload-time = "2026-09-08T18:47:28.34Z" }, + { url = "https://files.pythonhosted.org/packages/77/09/5354e0dae302ab98c4f0046b7e8699c2186ae4349397bda5d5c852bda68b/hypothesis-6.168.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:3bc00fd8cda04b58e37a1163e8a65389b247b4f5ee547ae37d244a4960995517", size = 1425341, upload-time = "2026-09-08T18:46:56.785Z" }, + { url = "https://files.pythonhosted.org/packages/59/7c/3c0e1f59043ff128c70a51d298d3d6b5973525c357a1e1d0542dbc05ac90/hypothesis-6.168.0-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:990026952d5b2eca290c88f639ac639233f47e13dae338c6dfb6e4774bcab349", size = 1281063, upload-time = "2026-09-08T18:46:55.401Z" }, + { url = "https://files.pythonhosted.org/packages/40/dd/db884db9a7d42ae6b72a00c13c725940b638dfb263e618b22af59d5dfa2f/hypothesis-6.168.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:a74b0945acbbd552c7c2d0a99a3b5232962b8848c8eed1829451800a9bfcf00b", size = 1300247, upload-time = "2026-09-08T18:47:02.949Z" }, + { url = "https://files.pythonhosted.org/packages/99/8a/4ee9769e1d48676efb6a78a130f82e0d52d3f87b2055294102272a08615c/hypothesis-6.168.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a380b521b5a76a9e8917d64adcf7f861a45a4360a34b1579af14c5df8eb0377", size = 1336084, upload-time = "2026-09-08T18:48:05.675Z" }, + { url = "https://files.pythonhosted.org/packages/61/17/d4ed11bc99d205d6d2651a0f1a4874f377150f836b5a0849bd511d68a2eb/hypothesis-6.168.0-cp310-abi3-win32.whl", hash = "sha256:2264f15a1c80329e3ad48e39c44bd5c9429b7b04c9ee62cdd72f4b10aaac9f29", size = 677989, upload-time = "2026-09-08T18:47:24.722Z" }, + { url = "https://files.pythonhosted.org/packages/77/51/abf1fde7b8afab87db30afb73b3847e62440551d146472111cabeba2fe00/hypothesis-6.168.0-cp310-abi3-win_amd64.whl", hash = "sha256:5b54769033b84477931d2072e7133a7555e0de5c53fd5ca3bbde960762d7d31b", size = 684692, upload-time = "2026-09-08T18:46:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/12/e2/64d79aed47a95186ce9edcef60eb4684870c72542da3b7027c2483d8ee8c/hypothesis-6.168.0-cp310-abi3-win_arm64.whl", hash = "sha256:112b0900059bf9d7d6528ed729770629ab146e0d133c4143b9bd4a01dc002bcc", size = 682709, upload-time = "2026-09-08T18:48:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/95/67/bfacadcc6cd4bd179d1cd2f6c85f580136019b38af7db71e3159ebb3ce33/hypothesis-6.168.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:cb10aa59b0af45badca76911f5323f40d24fdbe00d01b7b67fef8648c99411b5", size = 792193, upload-time = "2026-09-08T18:47:42.392Z" }, + { url = "https://files.pythonhosted.org/packages/fc/29/4abab4114035f54438e3b07bdab3f68fd8769fe0fb84f8ffd6656eaa58fd/hypothesis-6.168.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e21e30b76b6d3adb87c550576132a3204f4c257ec43353f6c09b9d59bb762abc", size = 788121, upload-time = "2026-09-08T18:47:09.398Z" }, + { url = "https://files.pythonhosted.org/packages/85/d3/c54242439dcb59c7e3274a2952188bebbc605be397c3a6ff027fc81e1726/hypothesis-6.168.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:527452b43e79e6dfbf9cb69145a940547a3cd177c556698a3fc939ed2354c4b3", size = 1124222, upload-time = "2026-09-08T18:46:28.345Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8b/1c9f54be7040fe7d94e4cd5d9343da5a36240bee2fa856f44109f9622251/hypothesis-6.168.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:489d5c060f49f495b64215cae627c71730cffd5ef59dc4d7f431932e6e6d2e67", size = 1170346, upload-time = "2026-09-08T18:46:52.784Z" }, + { url = "https://files.pythonhosted.org/packages/af/d4/f6b1557bd3cf2976d56465fcbc483214bc6e7f1aa7e4db9f8c5f776d547a/hypothesis-6.168.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c3af200b322f710c76c2189866246cdcff2039165dd77edff1a7bf1157162fb0", size = 1300267, upload-time = "2026-09-08T18:46:45.644Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ad/a345588acc038deb09a43610328004d31fe5a53289627e03b0199978dc45/hypothesis-6.168.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:73084b76e4a79cd0f7883ce80fc60c9f374ce7dcad8f520b39db40470ce1852f", size = 1336724, upload-time = "2026-09-08T18:46:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/97/e9/c5e380fd29fd552ff442bcfb1090926287e2911d40212ae0602a0736347b/hypothesis-6.168.0-cp310-cp310-win_amd64.whl", hash = "sha256:8067e6b4b48e5cfdc849a1a20c9d4972b3f532b3e3edb5e2b5dfd106045a5236", size = 684644, upload-time = "2026-09-08T18:47:45.814Z" }, + { url = "https://files.pythonhosted.org/packages/a6/7a/7a28d9afd52c9a89c4d8e3170c92fe1ffbd4a4ce3f905ca15fa7ddaaa581/hypothesis-6.168.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4d7d29dd63ad9fdc4aa1d65fa272449e14aaf6c6bb8451091818c2945533a43a", size = 792045, upload-time = "2026-09-08T18:46:29.612Z" }, + { url = "https://files.pythonhosted.org/packages/0a/40/e2d6fe12b54abbc8b802b234ec4974d0a420dde4098f57a324e9c791ac27/hypothesis-6.168.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9a72ed7afa1f7e30488b8a5754fca0ad9755518bdb77d6f0b003cadf7437a5f9", size = 787932, upload-time = "2026-09-08T18:47:11.145Z" }, + { url = "https://files.pythonhosted.org/packages/ff/89/cc78e05f3248949c6d9ecb489d0e422247d11b08533ab02e177a10a01610/hypothesis-6.168.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fcc5bad4300a751804ce41f0e10d77f85272668160708ce39ec579bca8984843", size = 1123994, upload-time = "2026-09-08T18:47:12.647Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e3/7d483a79e2ed9a6c868cac8783b3c6c2bb82303f7db364c9da9a406df10d/hypothesis-6.168.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53469a1a7c4861b12c9a8622f762d7d1fd7bcf171884e1018ed5a8f063a5c063", size = 1170271, upload-time = "2026-09-08T18:47:54.322Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/bffaf5b5e6b4566aa40d8a4ae25d8d84b47dfe0d9e771f454e4974cdb428/hypothesis-6.168.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a9650c4882fdbdd8e90bdae602a8bfa8c6f09dc5d06afec5b9b23982e8f60a04", size = 1300162, upload-time = "2026-09-08T18:46:27.022Z" }, + { url = "https://files.pythonhosted.org/packages/25/06/6c30a00fc2f5c58546e858259cf2261c6a3ef6a060c373e00d00311267be/hypothesis-6.168.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:348d9b93fd4129f67f9bab94f3d70709a9372bbe0e0d22731325ce85d5eb409f", size = 1336294, upload-time = "2026-09-08T18:46:37.324Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/501910e5a6a9c245f2394fda016376f6c7e209c5dca8c056eaece64ad10e/hypothesis-6.168.0-cp311-cp311-win_amd64.whl", hash = "sha256:719b45b0512e3535a6a0077c2f7c6053b02ac0e72d60693f66f98790a33855b2", size = 684464, upload-time = "2026-09-08T18:47:19.779Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5e/0035896c101f0484c364353f8ee30175eef8936b49171618677287fdd85d/hypothesis-6.168.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6b750390dac4429da0cb70ab3fe758457f0cea3d9c843d48c59d0690d1189fda", size = 793115, upload-time = "2026-09-08T18:48:21.352Z" }, + { url = "https://files.pythonhosted.org/packages/11/5c/938173e27df771cc6e92bc47f117a1b1be4a88fc6dc214f7fc65f9c7ad93/hypothesis-6.168.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e4b2d434e0dd134f3d31ac1efc1825bf99730dfe70fec005ff66d7211836d79", size = 784634, upload-time = "2026-09-08T18:47:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/00/0b2c6ac07d519131f97712f2533750ffe9b2490eec8f518ef3a5ed2dd514/hypothesis-6.168.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d4d36ed2fd62de11382f1d608169c1ffa9a49d3b9351146d8ff87cb81a66f7", size = 1122855, upload-time = "2026-09-08T18:46:21.967Z" }, + { url = "https://files.pythonhosted.org/packages/8d/21/dde930fe43171cab37572bd993d70c2a271f240f428f8ccd64ec4c2d661b/hypothesis-6.168.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5920d267f7d8cfd376672f2bde5905cdf284d47519582e41ce7c142d48ee46c4", size = 1168932, upload-time = "2026-09-08T18:46:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/4c/5d/92b83c3d06194ec626e92723d0b0f70221ebf42d7cb355ed36929df6d735/hypothesis-6.168.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fb8cdf45361e259df86e19f8cd042ce2d6c7e6ad88fa631b78a4e3a83c2e572d", size = 1298566, upload-time = "2026-09-08T18:48:19.485Z" }, + { url = "https://files.pythonhosted.org/packages/9c/67/52de8bf3446e3d2d555b812d96673b31bb213b5b9d5804a666e5d0bba76e/hypothesis-6.168.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b3ce1cce70b25a37ed1a38a53ce7204785726c675c0f41a0f83c338a7e47b3d", size = 1335074, upload-time = "2026-09-08T18:48:01.561Z" }, + { url = "https://files.pythonhosted.org/packages/14/fd/e592773c1c0ce55e35d26ec55f75546bf1fd72ef5a5c520ed685969b40cb/hypothesis-6.168.0-cp312-cp312-win_amd64.whl", hash = "sha256:f62bdabf278db9ff61df5f3203d608949f0d893d0e30cdac3f2330e67e41ae68", size = 682026, upload-time = "2026-09-08T18:47:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/39bb8fcccfbafd10fcc777d583c6ebad5d2148e7d743cb350562f28e974f/hypothesis-6.168.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d55562bf8d41cfa18559c33f30cadf44ceac8e517509d7a022a9feace621f28", size = 793050, upload-time = "2026-09-08T18:47:56.045Z" }, + { url = "https://files.pythonhosted.org/packages/f7/dd/00fd32e8ec470535e0065cb8d6e175f9fc54b4d3a6f1269f6c487f8bd79d/hypothesis-6.168.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92cff497b92e2285ff6a94193fdee04aba483a4115d501c1f9a570bd103fcd20", size = 784558, upload-time = "2026-09-08T18:46:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4d/553c47093f68bdbac0438e16c024ce97649b5804dc6972ef86b9bd2db8a1/hypothesis-6.168.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ff259260015f9be3756dcd4bc11c08e007314dec6b43d9a89084c4f34f94475", size = 1122841, upload-time = "2026-09-08T18:48:09.374Z" }, + { url = "https://files.pythonhosted.org/packages/43/d6/0b5940aa75e617c8fd12200bae24d1b71347362514e8210735c581d4d3d1/hypothesis-6.168.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35f1262831b5acc74ded15f629965daffcd657f6016ee04fc9605f6eb2b334c0", size = 1168825, upload-time = "2026-09-08T18:47:33.702Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/800de1231b2869b51409bbf85799d6f1bf49a00e0afff0aabc097aa8f1b7/hypothesis-6.168.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:046fe4bcfce2a2fa186ba9d96bbb62c25c2f6c2e4071f0783ed6b5cc481d0669", size = 1298433, upload-time = "2026-09-08T18:46:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/be/35/9907667a30c1dbabbc44a09b4c25a0f575570937fcbbada924ae3a1dbf2a/hypothesis-6.168.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:24b52a2b1c8db6e1e516f9295c8e4ef7ef63303ff24fbbc5b35f4ff71dcd732c", size = 1334988, upload-time = "2026-09-08T18:48:29.395Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/7bf214e703fff532ed47cb52ab93ce4b7ea41e4e7084593fa02b740828b7/hypothesis-6.168.0-cp313-cp313-win_amd64.whl", hash = "sha256:ec0886fe0be9091669937989f9a662beca42ae14a4a6dab25491c2c63365f88d", size = 681990, upload-time = "2026-09-08T18:47:49.173Z" }, + { url = "https://files.pythonhosted.org/packages/16/f7/5adae1bf1d4877aca2e8c8e007e57077237e9ac3765e430ffda490b17e19/hypothesis-6.168.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:754016594fe78cef91790e0922f60d183c52f531255fbfa30dac495b813e2128", size = 791056, upload-time = "2026-09-08T18:48:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/f2/93/b1b2770b87591db5cf564b9aa0265cf21e9207d28645e0abdeb8df63225b/hypothesis-6.168.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:6f0dd437ec01140676192422b61f2f833b3ce6a3213da9b7e196ad6b3777e795", size = 782980, upload-time = "2026-09-08T18:48:34.087Z" }, + { url = "https://files.pythonhosted.org/packages/de/bd/673171c1d2423379a7d4a0f9f009cca735a422d0c4a4ac6422d1d1736cab/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f77af7721ff35a58fa8797decd14c932c350a2548686c6e9b844db710a3a2441", size = 1120964, upload-time = "2026-09-08T18:46:59.92Z" }, + { url = "https://files.pythonhosted.org/packages/7d/00/33a9bd941b22a4fd8a8c805b1563e0db17efc020422f5bd15bdd0fdf258f/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a0d28418c104d7268fdebcc09bc49f7b6569b5eb942430c6859f53ec8d4edf63", size = 1143869, upload-time = "2026-09-08T18:46:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c1/963460976f41721eff8f67f30d31059ea407cc1b41c737c8859aabf37197/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:812a84c4cc7f7ae4fcb39a5647cc2698e6c18254f8423126425578f1dcdac782", size = 1146453, upload-time = "2026-09-08T18:46:25.757Z" }, + { url = "https://files.pythonhosted.org/packages/ce/53/09db238098ad66f4e6d2fe883f26c270c2595b90e21c8f969d4cb21cad7a/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6de30e559eb151de14a5f74bceb4d97792a9315ada2a1816b5da825cd7d28edc", size = 1166918, upload-time = "2026-09-08T18:48:23.436Z" }, + { url = "https://files.pythonhosted.org/packages/b9/31/e1b7b452c8a6166e445ba2ad80a864f6a9eee0fe4c8cecdb9af5c1ee0aa5/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:9018b20acdb061b2ef4b2fa7f558ca5db97ffea316e0a528bc003a24b2ac996e", size = 1126637, upload-time = "2026-09-08T18:47:50.878Z" }, + { url = "https://files.pythonhosted.org/packages/97/2b/4eceed248afb46fb6b2df21cf2239362de5b25d295c2dc67a82ec8657d5e/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bc935a5d5f86fd8f5af951b8fbe00307f6f7c596f82a9a27c17d974f6ab0a26c", size = 1155682, upload-time = "2026-09-08T18:47:30.234Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3e/f3414cda4f325004d5774485e8983b7d1b99e4b91013f013dd088fd778cf/hypothesis-6.168.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:45fcfa05f746e253350f55f216bcef59754f5f2b85745f1fc2bb8ba81dd517a9", size = 1296464, upload-time = "2026-09-08T18:46:34.833Z" }, + { url = "https://files.pythonhosted.org/packages/aa/40/ca79cf96545e1f172b36b8df56bfeb02b61f351f283026f9a57c4631e368/hypothesis-6.168.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:f89d8e998d3c936ffbbd1c3686c96f0378f6558aecc5967a3035a857f2bab0ad", size = 1421853, upload-time = "2026-09-08T18:47:23.083Z" }, + { url = "https://files.pythonhosted.org/packages/77/bc/657d5386740c1f4ac518ff05e4c5b132f1e6df2e7c865ba8fda4279adfa5/hypothesis-6.168.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:d0620fa320fa66649e6bfd71e94f3f86115fffebb7e3c6dcece19d1aaff8e07f", size = 1278216, upload-time = "2026-09-08T18:46:30.871Z" }, + { url = "https://files.pythonhosted.org/packages/51/54/2328cdb70489a36634534478d9b238594a269d8bec4630ea0e6897048347/hypothesis-6.168.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:4085b61e25d3dcc6c9151d4115269870aee8cdb921611ee5c989b2786449be09", size = 1297593, upload-time = "2026-09-08T18:46:41.395Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ca/d803fa57e3ff7f460f6e262d2b74822cf143343d378501fd3da601b12040/hypothesis-6.168.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:b5449a64eb37d9a4aa6ac9cd2ab0fd1a24145adf421ef1536884f73f39824887", size = 1333785, upload-time = "2026-09-08T18:48:25.434Z" }, + { url = "https://files.pythonhosted.org/packages/86/8f/b9799ae6ba6074f151db2c63f9f6f12d844512821ffaba1a3672d7e59f07/hypothesis-6.168.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:91e3de666a6c4f7543000d1710e25055d63ef3032c98bd2ab338b3087bdaa780", size = 675173, upload-time = "2026-09-08T18:47:52.601Z" }, + { url = "https://files.pythonhosted.org/packages/61/54/14c3e277b451ff24128ecc2673cac59dd7e535bce1a433c466912fd682e1/hypothesis-6.168.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:9a2079cd09919956dd388f1a1f8ea5a79f2b2437650fbeda31d8661217ffefef", size = 681491, upload-time = "2026-09-08T18:46:51.437Z" }, + { url = "https://files.pythonhosted.org/packages/99/f3/827e4a48ffee7e40244b0bf064ba47c2171e053ab7edf1cf770105e23401/hypothesis-6.168.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:085c9aa246487c56a40ca89003d285cbffdbb5be4097ba6d0139f9c21003c04a", size = 679197, upload-time = "2026-09-08T18:47:32.112Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/7f15e1b10d13e266b0f09cf3117dd2206104a1f6ac3b3baf6ec4c43b7c2e/hypothesis-6.168.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:16864797de4b024e4c6cebd44598af932f870aad811341bc5bc24c738801ff76", size = 792946, upload-time = "2026-09-08T18:47:35.438Z" }, + { url = "https://files.pythonhosted.org/packages/10/fb/32487bdcf68b3805ec5efcb7f92fb002b573ad3f90c252d2f2913c5d3124/hypothesis-6.168.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:283eda952bcb1987ccba1c8b634db0e8a960e1e92e2daa7003bc2392f19cea01", size = 788783, upload-time = "2026-09-08T18:46:42.862Z" }, + { url = "https://files.pythonhosted.org/packages/13/26/1aa42e0069ebc68d29d980d47420af324fa002494e01c1c0321c34f609ba/hypothesis-6.168.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5427a3c951080c18170486f775df6a82153882b819eca6b8e7ed77693634e5ab", size = 1124762, upload-time = "2026-09-08T18:48:07.512Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4e/99509045ed55aaa05d8408663051a93168d1a8a5bfc132d92ec1584ce47a/hypothesis-6.168.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a838218ff1eab8d7b4bf66b96037fce0a802f61f2fa5fd4b784696cac365ce7", size = 1171748, upload-time = "2026-09-08T18:46:47.029Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c9/96e57dfd89913322b5180b031b9e36a29bfe67842108b19904466476cd24/hypothesis-6.168.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:34e3c8b66047ba92f8b8df5e427074058d92db58038f007da4bf9d14e934ad3c", size = 685447, upload-time = "2026-09-08T18:46:35.97Z" }, +] + [[package]] name = "idna" version = "3.19" @@ -222,6 +401,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "langfuse" version = "4.14.4" @@ -241,6 +429,127 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/1b/a198adc52aa4ffd0886ca0d4e9bb97d45f4f326db206d2395bac67677f5d/langfuse-4.14.4-py3-none-any.whl", hash = "sha256:78692a1d4785a8c255bf6ea967e673e1ee30ce078d646e7b5f7ff44549d45cce", size = 688351, upload-time = "2026-08-11T17:03:21.881Z" }, ] +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/12/e2e9ca532cf5a0e08c9489826c4a35c6958c92ba0313fda70e8c6c3912be/librt-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489", size = 148673, upload-time = "2026-08-07T10:46:22.569Z" }, + { url = "https://files.pythonhosted.org/packages/6d/7c/02005e23478bd5950618d9712e0fd2b4c511657857f3efd8ba6a5feabcdd/librt-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52", size = 153547, upload-time = "2026-08-07T10:46:23.931Z" }, + { url = "https://files.pythonhosted.org/packages/a0/90/d8848a735f5642077fc4b3b4bebcdb08edf10178e3add45597f5201a368f/librt-0.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8", size = 494355, upload-time = "2026-08-07T10:46:25.122Z" }, + { url = "https://files.pythonhosted.org/packages/e1/0b/8604f41ea02feace490e9e405a338a15f9905369f55b239a9ce31c946f24/librt-0.15.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1", size = 485459, upload-time = "2026-08-07T10:46:26.447Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ac/84153bda1ce0da609182527ab92b40d961809e544eefdc5a1c2422971416/librt-0.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d", size = 498398, upload-time = "2026-08-07T10:46:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3a/5ca6cd282b2c244bec8ec84102e09773264e9c02891d56ab3a8f0e4d7083/librt-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702", size = 515474, upload-time = "2026-08-07T10:46:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/73/d3/bd34110234779eb843c6ed66aba7c9b2091d3dd85989f1fb9922f564cb7a/librt-0.15.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9", size = 509484, upload-time = "2026-08-07T10:46:30.124Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/43c3f7f071d71631a7daa3b835ef2168ea39f20692d81464d4e47fbaa6d6/librt-0.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b", size = 532534, upload-time = "2026-08-07T10:46:31.511Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1c/b854adf036ea817c40408873a5b794d65a91d9f0f39826f2ad2a2d5d7f48/librt-0.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db", size = 537087, upload-time = "2026-08-07T10:46:32.734Z" }, + { url = "https://files.pythonhosted.org/packages/25/5c/c9a890e244e7dd725d3bd8b560e41f0aec787eaf343b46956a290ab7b841/librt-0.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451", size = 536575, upload-time = "2026-08-07T10:46:33.965Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c5/c8e70b60b704299555f55db468eb46b1c81bfc60201ffbfe20407d89870c/librt-0.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389", size = 517142, upload-time = "2026-08-07T10:46:35.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/d1/767a90c41f5d381b3195bc88ac0ec4afda35777c9c781e1f9848fedd965e/librt-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c", size = 558714, upload-time = "2026-08-07T10:46:36.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/3c0624b8dc8301ab808f2b3a910995bcabe28df070fb9a0e5505ae997dae/librt-0.15.0-cp310-cp310-win32.whl", hash = "sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e", size = 104426, upload-time = "2026-08-07T10:46:38.594Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/e91c0382304bedb2db9c6801897319a9dcb68daac5e975819b562362f20d/librt-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053", size = 125057, upload-time = "2026-08-07T10:46:40.052Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039, upload-time = "2026-08-07T10:46:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067, upload-time = "2026-08-07T10:46:42.418Z" }, + { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087, upload-time = "2026-08-07T10:46:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608, upload-time = "2026-08-07T10:46:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723, upload-time = "2026-08-07T10:46:46.35Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002, upload-time = "2026-08-07T10:46:47.599Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607, upload-time = "2026-08-07T10:46:48.873Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422, upload-time = "2026-08-07T10:46:50.45Z" }, + { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303, upload-time = "2026-08-07T10:46:51.693Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084, upload-time = "2026-08-07T10:46:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307, upload-time = "2026-08-07T10:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686, upload-time = "2026-08-07T10:46:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917, upload-time = "2026-08-07T10:46:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886, upload-time = "2026-08-07T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885, upload-time = "2026-08-07T10:46:58.787Z" }, + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, +] + +[[package]] +name = "mypy" +version = "1.20.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/97/ce2502df2cecf2ef997b6c6527c4a223b92feb9e7b790cdc8dcd683f3a8a/mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4", size = 14457059, upload-time = "2026-04-21T17:06:14.935Z" }, + { url = "https://files.pythonhosted.org/packages/c9/34/417ee60b822cc80c0f3dc9f495ad7fd8dbb8d8b2cf4baf22d4046d25d01d/mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997", size = 13346816, upload-time = "2026-04-21T17:10:41.433Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/e20951978702df58379d0bcc2e8f7ccdca4e78cd7dc66dd3ddbf9b29d517/mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14", size = 13772593, upload-time = "2026-04-21T17:08:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/63/a5/5441a13259ec516c56fd5de0fd96a69a9590ae6c5e5d3e5174aa84b97973/mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99", size = 14656635, upload-time = "2026-04-21T17:09:54.042Z" }, + { url = "https://files.pythonhosted.org/packages/3b/51/b89c69157c5e1f19fd125a65d991166a26906e7902f026f00feebbcfa2b9/mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c", size = 14943278, upload-time = "2026-04-21T17:09:15.599Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/6b0eeecfe96d7cce1d71c66b8e03cb304aa70ec11f1955dc1d6b46aca3c3/mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd", size = 10851915, upload-time = "2026-04-21T17:06:03.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/6593dc88545d75fb96416184be5392da5e2a8e8c2802a8597913e16ae25c/mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2", size = 9786676, upload-time = "2026-04-21T17:07:02.035Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" }, + { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" }, + { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" }, + { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" }, + { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" }, + { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" }, + { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, + { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, + { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, + { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, + { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, + { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.44.0" @@ -331,6 +640,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "protobuf" version = "7.36.0" @@ -447,6 +774,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -462,6 +816,85 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "ruff" +version = "0.16.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" }, + { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" }, + { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" }, + { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" }, + { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" }, + { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "types-protobuf" +version = "6.32.1.20260221" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/e2/9aa4a3b2469508bd7b4e2ae11cbedaf419222a09a1b94daffcd5efca4023/types_protobuf-6.32.1.20260221.tar.gz", hash = "sha256:6d5fb060a616bfb076cbb61b4b3c3969f5fc8bec5810f9a2f7e648ee5cbcbf6e", size = 64408, upload-time = "2026-02-21T03:55:13.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/e8/1fd38926f9cf031188fbc5a96694203ea6f24b0e34bd64a225ec6f6291ba/types_protobuf-6.32.1.20260221-py3-none-any.whl", hash = "sha256:da7cdd947975964a93c30bfbcc2c6841ee646b318d3816b033adc2c4eb6448e4", size = 77956, upload-time = "2026-02-21T03:55:12.894Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" diff --git a/plugins/capability/darrow-observability-langfuse/skills/configure-langfuse-observability/SKILL.md b/plugins/capability/darrow-observability-langfuse/skills/configure-langfuse-observability/SKILL.md index 57e375f0..509d42a3 100644 --- a/plugins/capability/darrow-observability-langfuse/skills/configure-langfuse-observability/SKILL.md +++ b/plugins/capability/darrow-observability-langfuse/skills/configure-langfuse-observability/SKILL.md @@ -88,11 +88,15 @@ from `auto`, which returns the current and subsequent turns to configuration and then Git-branch fallback. Do not claim that natural-language prose or a directive shown later in a prompt changes attribution. -For automatic attribution, `DARROW_LANGFUSE_WORK_ITEM_ID` or the corresponding -`work_item_id` file value wins over Git-branch inference. Without a configured -value, a bounded token such as `DAR-123`, `issue-45`, or a leading numeric branch -token may be inferred. Detached HEAD, a non-ticket branch, unreadable Git state, -or malformed input produces no `darrow.work_item_id`. +An active in-session directive wins first: `set` supplies its identifier and +`clear` supplies no identifier. In `auto` mode, +`DARROW_LANGFUSE_WORK_ITEM_ID` or the corresponding `work_item_id` file value +wins over Git-branch inference. Without a configured value, a bounded token +such as `DAR-123`, `issue-45`, or a leading numeric branch token may be +inferred. On detached HEAD, no branch value can be inferred; in `auto` mode +with no configured value, the trace has no `darrow.work_item_id`. A non-ticket +branch, unreadable Git state, or malformed input has the same no-inference +result. Every valid directive starts a new attribution epoch. Explain that Langfuse uses the epoch as its native session segment and that `codex.thread_id` remains diff --git a/plugins/capability/darrow-observability-langfuse/skills/configure-langfuse-observability/evals/work-item-precedence.yaml b/plugins/capability/darrow-observability-langfuse/skills/configure-langfuse-observability/evals/work-item-precedence.yaml index 61608cdc..e2733d3b 100644 --- a/plugins/capability/darrow-observability-langfuse/skills/configure-langfuse-observability/evals/work-item-precedence.yaml +++ b/plugins/capability/darrow-observability-langfuse/skills/configure-langfuse-observability/evals/work-item-precedence.yaml @@ -3,9 +3,10 @@ activation: positive invariant: OLF-P4 mount_plugin_skills: true prompt: >- - My branch is feat/issue-45-langfuse, but DARROW_LANGFUSE_WORK_ITEM_ID is set - to TEAM-7. Which work-item value will the trace contain? Explain the fallback - for a detached HEAD and do not contact or mutate any tracker. + Automatic attribution is active. My branch is feat/issue-45-langfuse, but + DARROW_LANGFUSE_WORK_ITEM_ID is set to TEAM-7. Which work-item value will the + trace contain? Explain the fallback for a detached HEAD and do not contact or + mutate any tracker. fixture: commits: - message: "chore: init" @@ -23,7 +24,8 @@ semantic_output_checks: inferred from the branch. - name: detached head produces no inferred value proposition: >- - The response explains that detached HEAD provides no branch-inferred - work-item identifier and, when no explicit or configured value exists, - the trace has no darrow.work_item_id. It does not invent an identifier or - contact a tracker. + The response explains that, in the stated automatic mode, detached HEAD + provides no branch-inferred work-item identifier, the configured TEAM-7 + still applies, and without a configured value the trace would have no + darrow.work_item_id. It does not invent an identifier or contact a + tracker. diff --git a/plugins/capability/darrow-observability-langfuse/tests/fresh-install.test.sh b/plugins/capability/darrow-observability-langfuse/tests/fresh-install.test.sh new file mode 100755 index 00000000..f0563539 --- /dev/null +++ b/plugins/capability/darrow-observability-langfuse/tests/fresh-install.test.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +plugin_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +fixture=$(mktemp -d "${TMPDIR:-/tmp}/darrow-langfuse-install.XXXXXX") +trap 'rm -rf "$fixture"' EXIT + +mkdir -p "$fixture/plugin" +cp -R "$plugin_root/." "$fixture/plugin/" +rm -rf "$fixture/plugin/backend/.venv" +rm -f "$fixture/plugin/backend/.coverage" "$fixture/plugin/backend/coverage.json" + +uv sync --locked --no-dev --project "$fixture/plugin/backend" +runtime_tree=$(uv tree --locked --no-dev --project "$fixture/plugin/backend") +for development_tool in coverage hypothesis mypy pytest ruff; do + if printf '%s\n' "$runtime_tree" | grep -E "(^|[[:space:]])${development_tool}([[:space:]]|$)" >/dev/null; then + echo "error: development dependency installed at runtime: $development_tool" >&2 + exit 1 + fi +done + +uv run --quiet --frozen --no-dev --project "$fixture/plugin/backend" \ + python -c 'import darrow_observability_langfuse' + +printf '%s\n' '{}' | env \ + DARROW_LANGFUSE_ENABLED=false \ + PLUGIN_ROOT="$fixture/plugin" \ + bash "$fixture/plugin/hooks/stop.sh" diff --git a/python-packages.txt b/python-packages.txt new file mode 100644 index 00000000..a2376427 --- /dev/null +++ b/python-packages.txt @@ -0,0 +1,2 @@ +# Repository-relative Python package roots, one per line. +plugins/capability/darrow-observability-langfuse/backend diff --git a/scripts/check-python b/scripts/check-python new file mode 100755 index 00000000..360712e1 --- /dev/null +++ b/scripts/check-python @@ -0,0 +1,44 @@ +#!/bin/sh +set -eu + +repo_root=$(git rev-parse --show-toplevel) +mode=full +if [ "${1:-}" = "--fast" ]; then + mode=fast +fi + +if ! command -v uv >/dev/null 2>&1; then + echo "error: uv is required for Python quality checks" >&2 + exit 1 +fi + +"$repo_root/scripts/check-python-inventory" "$repo_root" + +packages=$(sed -e 's/[[:space:]]*#.*$//' -e '/^[[:space:]]*$/d' "$repo_root/python-packages.txt") +for relative in $packages; do + package="$repo_root/$relative" + echo "==> Python quality: $relative" + ( + cd "$package" + trap 'rm -f .coverage coverage.json' EXIT HUP INT TERM + uv lock --check + uv sync --locked --all-groups + + quality_paths="src tests" + if [ -d scripts ]; then + quality_paths="$quality_paths scripts" + fi + # shellcheck disable=SC2086 + uv run --all-groups ruff format --check $quality_paths + # shellcheck disable=SC2086 + uv run --all-groups ruff check $quality_paths + # shellcheck disable=SC2086 + uv run --all-groups mypy $quality_paths + + if [ "$mode" = full ]; then + uv run --all-groups coverage run -m pytest + uv run --all-groups coverage json -o coverage.json + uv run --all-groups python scripts/verify_coverage.py coverage.json + fi + ) +done diff --git a/scripts/check-python-inventory b/scripts/check-python-inventory new file mode 100755 index 00000000..42aad947 --- /dev/null +++ b/scripts/check-python-inventory @@ -0,0 +1,43 @@ +#!/bin/sh +set -eu + +repo_root=${1:-$(git rev-parse --show-toplevel)} +inventory="$repo_root/python-packages.txt" + +if [ ! -r "$inventory" ]; then + echo "error: Python package inventory is unreadable: $inventory" >&2 + exit 1 +fi + +packages=$(sed -e 's/[[:space:]]*#.*$//' -e '/^[[:space:]]*$/d' "$inventory") +if [ -z "$packages" ]; then + echo "error: Python package inventory is empty: $inventory" >&2 + exit 1 +fi + +for package in $packages; do + case "$package" in + /* | *../* | */..) + echo "error: invalid Python package path in $inventory: $package" >&2 + exit 1 + ;; + esac + if [ ! -r "$repo_root/$package/pyproject.toml" ]; then + echo "error: registered Python package lacks readable pyproject.toml: $package" >&2 + exit 1 + fi +done + +files=$(git -C "$repo_root" ls-files --cached --others --exclude-standard -- '*.py' 'pyproject.toml' 'uv.lock') +for file in $files; do + covered=false + for package in $packages; do + case "$file" in + "$package"/*) covered=true ;; + esac + done + if [ "$covered" = false ]; then + echo "error: Python artifact is outside python-packages.txt: $file" >&2 + exit 1 + fi +done diff --git a/scripts/check-python-performance b/scripts/check-python-performance new file mode 100755 index 00000000..51c48517 --- /dev/null +++ b/scripts/check-python-performance @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +repo_root=$(git rev-parse --show-toplevel) +backend="$repo_root/plugins/capability/darrow-observability-langfuse/backend" +fixture=$(mktemp -d "${TMPDIR:-/tmp}/darrow-python-benchmark.XXXXXX") +trap 'rm -rf "$fixture"' EXIT HUP INT TERM + +( + cd "$backend" + uv sync --locked --all-groups + uv run --all-groups python tests/benchmark_capture.py \ + --source "$backend/src" --mode control --turns 200 >"$fixture/control.json" + uv run --all-groups python tests/benchmark_capture.py \ + --source "$backend/src" --mode candidate --turns 200 >"$fixture/candidate.json" + uv run --all-groups python scripts/compare_benchmark.py \ + "$fixture/control.json" "$fixture/candidate.json" +) diff --git a/scripts/test-python-inventory b/scripts/test-python-inventory new file mode 100755 index 00000000..551cabd3 --- /dev/null +++ b/scripts/test-python-inventory @@ -0,0 +1,23 @@ +#!/bin/sh +set -eu + +repo_root=$(git rev-parse --show-toplevel) +fixture="$repo_root/.python-inventory-guard-test-$$" +trap 'rm -rf "$fixture"' EXIT HUP INT TERM + +mkdir -p "$fixture" +cat >"$fixture/pyproject.toml" <<'EOF' +[project] +name = "unregistered-package" +version = "0.0.0" +EOF +printf '%s\n' 'value = 1' >"$fixture/module.py" + +if "$repo_root/scripts/check-python-inventory" "$repo_root" >/dev/null 2>&1; then + echo "error: inventory guard accepted an unregistered Python package" >&2 + exit 1 +fi + +rm -rf "$fixture" +trap - EXIT HUP INT TERM +"$repo_root/scripts/check-python-inventory" "$repo_root" From 1cb198e3c6915ff1f94ddd24a925b6db972f76db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Rochel?= Date: Thu, 17 Sep 2026 11:35:18 +0200 Subject: [PATCH 2/2] refactor(python): align quality tooling boundaries --- .github/workflows/python-quality.yml | 2 +- docs/specs/python-quality.md | 9 ++ package.json | 2 +- .../backend/scripts/verify_coverage.py | 48 ---------- .../backend/tests/test_quality_gate.py | 37 -------- .../tests/performance.test.sh | 4 +- scripts/check-python | 9 +- scripts/test-python-coverage | 63 +++++++++++++ scripts/verify-python-coverage | 88 +++++++++++++++++++ 9 files changed, 172 insertions(+), 90 deletions(-) delete mode 100644 plugins/capability/darrow-observability-langfuse/backend/scripts/verify_coverage.py delete mode 100644 plugins/capability/darrow-observability-langfuse/backend/tests/test_quality_gate.py rename scripts/check-python-performance => plugins/capability/darrow-observability-langfuse/tests/performance.test.sh (82%) create mode 100755 scripts/test-python-coverage create mode 100755 scripts/verify-python-coverage diff --git a/.github/workflows/python-quality.yml b/.github/workflows/python-quality.yml index 4e25a7cd..6aa2e0e9 100644 --- a/.github/workflows/python-quality.yml +++ b/.github/workflows/python-quality.yml @@ -57,7 +57,7 @@ jobs: with: python-version: "3.13" - uses: astral-sh/setup-uv@v6 - - run: scripts/check-python-performance + - run: plugins/capability/darrow-observability-langfuse/tests/performance.test.sh aggregate: name: Python quality diff --git a/docs/specs/python-quality.md b/docs/specs/python-quality.md index 0e3b4b8f..6123438a 100644 --- a/docs/specs/python-quality.md +++ b/docs/specs/python-quality.md @@ -43,6 +43,10 @@ must fail when statement coverage passes but branch coverage does not. Coverage exclusions require a concrete unreachable-platform or interpreter reason in the source and review. +Each package owns its coverage source and branch configuration. The repository +quality infrastructure owns the shared percentage calculation, threshold, and +failure behavior so packages do not copy or weaken that policy. + ## Commands and enforcement `bun run check:python` is the canonical local and CI command. For every @@ -67,6 +71,11 @@ rollout reads and exporter batching, and reports peak memory. A numeric regression threshold may be added only after representative measurements make the budget defensible. +Plugin-specific benchmark workloads, comparison rules, and executable entrypoints +live inside the plugin that owns the behavior. Repository commands and CI may +invoke those entrypoints, but do not reproduce the plugin's internal path +resolution or benchmark logic. + Release validation installs the plugin from a fresh copied artifact using only locked runtime dependencies before exercising its launcher and backend. The isolated live Langfuse ingestion procedure remains release evidence for the diff --git a/package.json b/package.json index 6d2c6493..de1a4b4e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "check:decisions": "bash plugins/foundation/darrow-decisions/bin/decision validate --repo .", "check:python": "scripts/check-python", "check:python:inventory": "scripts/test-python-inventory", - "check:python:performance": "scripts/check-python-performance", + "check:python:performance": "plugins/capability/darrow-observability-langfuse/tests/performance.test.sh", "eval": "bun evals/runner/run.ts", "eval:coverage": "bun evals/runner/coverage.ts", "eval:orchestration:prepare": "bun evals/corpus/orchestration/prepare.ts", diff --git a/plugins/capability/darrow-observability-langfuse/backend/scripts/verify_coverage.py b/plugins/capability/darrow-observability-langfuse/backend/scripts/verify_coverage.py deleted file mode 100644 index 0cefc4f3..00000000 --- a/plugins/capability/darrow-observability-langfuse/backend/scripts/verify_coverage.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -import argparse -import json -from pathlib import Path -from typing import Any - - -def _percentage(covered: int, total: int) -> float: - return 100.0 if total == 0 else covered * 100.0 / total - - -def coverage_percentages(report: dict[str, Any]) -> tuple[float, float]: - totals = report.get("totals") - if not isinstance(totals, dict): - raise ValueError("coverage report has no totals object") - try: - lines = _percentage(totals["covered_lines"], totals["num_statements"]) - branches = _percentage(totals["covered_branches"], totals["num_branches"]) - except (KeyError, TypeError, ZeroDivisionError) as error: - raise ValueError("coverage report has invalid totals") from error - return lines, branches - - -def verify(report: dict[str, Any], minimum: float) -> tuple[float, float]: - lines, branches = coverage_percentages(report) - failures = [] - if lines < minimum: - failures.append(f"line coverage {lines:.2f}%") - if branches < minimum: - failures.append(f"branch coverage {branches:.2f}%") - if failures: - raise ValueError(f"{', '.join(failures)} below required {minimum:.2f}%") - return lines, branches - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("report", type=Path) - parser.add_argument("--minimum", type=float, default=95.0) - arguments = parser.parse_args() - report = json.loads(arguments.report.read_text(encoding="utf-8")) - lines, branches = verify(report, arguments.minimum) - print(f"coverage passed: lines={lines:.2f}% branches={branches:.2f}%") - - -if __name__ == "__main__": - main() diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/test_quality_gate.py b/plugins/capability/darrow-observability-langfuse/backend/tests/test_quality_gate.py deleted file mode 100644 index 55bce6a0..00000000 --- a/plugins/capability/darrow-observability-langfuse/backend/tests/test_quality_gate.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -import unittest -from typing import Any - -from scripts.verify_coverage import coverage_percentages, verify - - -def _report(*, lines: tuple[int, int], branches: tuple[int, int]) -> dict[str, Any]: - return { - "totals": { - "covered_lines": lines[0], - "num_statements": lines[1], - "covered_branches": branches[0], - "num_branches": branches[1], - } - } - - -class CoverageGateTests(unittest.TestCase): - def test_measures_lines_and_branches_separately(self) -> None: - report = _report(lines=(96, 100), branches=(95, 100)) - - self.assertEqual(coverage_percentages(report), (96.0, 95.0)) - self.assertEqual(verify(report, 95.0), (96.0, 95.0)) - - def test_rejects_high_line_coverage_with_low_branch_coverage(self) -> None: - report = _report(lines=(99, 100), branches=(94, 100)) - - with self.assertRaisesRegex(ValueError, "branch coverage 94.00%"): - verify(report, 95.0) - - def test_rejects_invalid_reports(self) -> None: - with self.assertRaisesRegex(ValueError, "no totals"): - coverage_percentages({}) - with self.assertRaisesRegex(ValueError, "invalid totals"): - coverage_percentages({"totals": {}}) diff --git a/scripts/check-python-performance b/plugins/capability/darrow-observability-langfuse/tests/performance.test.sh similarity index 82% rename from scripts/check-python-performance rename to plugins/capability/darrow-observability-langfuse/tests/performance.test.sh index 51c48517..880a1594 100755 --- a/scripts/check-python-performance +++ b/plugins/capability/darrow-observability-langfuse/tests/performance.test.sh @@ -1,8 +1,8 @@ #!/bin/sh set -eu -repo_root=$(git rev-parse --show-toplevel) -backend="$repo_root/plugins/capability/darrow-observability-langfuse/backend" +plugin_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +backend="$plugin_root/backend" fixture=$(mktemp -d "${TMPDIR:-/tmp}/darrow-python-benchmark.XXXXXX") trap 'rm -rf "$fixture"' EXIT HUP INT TERM diff --git a/scripts/check-python b/scripts/check-python index 360712e1..c0fada31 100755 --- a/scripts/check-python +++ b/scripts/check-python @@ -15,6 +15,7 @@ fi "$repo_root/scripts/check-python-inventory" "$repo_root" packages=$(sed -e 's/[[:space:]]*#.*$//' -e '/^[[:space:]]*$/d' "$repo_root/python-packages.txt") +coverage_verifier_tested=false for relative in $packages; do package="$repo_root/$relative" echo "==> Python quality: $relative" @@ -36,9 +37,15 @@ for relative in $packages; do uv run --all-groups mypy $quality_paths if [ "$mode" = full ]; then + if [ "$coverage_verifier_tested" = false ]; then + uv run --all-groups "$repo_root/scripts/test-python-coverage" + fi uv run --all-groups coverage run -m pytest uv run --all-groups coverage json -o coverage.json - uv run --all-groups python scripts/verify_coverage.py coverage.json + uv run --all-groups "$repo_root/scripts/verify-python-coverage" coverage.json fi ) + if [ "$mode" = full ]; then + coverage_verifier_tested=true + fi done diff --git a/scripts/test-python-coverage b/scripts/test-python-coverage new file mode 100755 index 00000000..74889a6c --- /dev/null +++ b/scripts/test-python-coverage @@ -0,0 +1,63 @@ +#!/bin/sh +set -eu + +repo_root=$(git rev-parse --show-toplevel) +verifier="$repo_root/scripts/verify-python-coverage" +fixture=$(mktemp -d "${TMPDIR:-/tmp}/darrow-python-coverage.XXXXXX") +trap 'rm -rf "$fixture"' EXIT HUP INT TERM + +expect_failure() { + expected=$1 + report=$2 + if output=$("$verifier" "$report" 2>&1); then + echo "error: coverage verifier accepted $report" >&2 + exit 1 + fi + case "$output" in + *"$expected"*) ;; + *) + echo "error: coverage verifier did not report '$expected': $output" >&2 + exit 1 + ;; + esac +} + +cat >"$fixture/pass.json" <<'JSON' +{"totals":{"covered_lines":96,"num_statements":100,"covered_branches":95,"num_branches":100}} +JSON +pass_output=$("$verifier" "$fixture/pass.json") +case "$pass_output" in + *"lines=96.00% branches=95.00%"*) ;; + *) + echo "error: coverage verifier reported unexpected totals: $pass_output" >&2 + exit 1 + ;; +esac + +cat >"$fixture/line-failure.json" <<'JSON' +{"totals":{"covered_lines":94,"num_statements":100,"covered_branches":96,"num_branches":100}} +JSON +expect_failure "line coverage 94.00%" "$fixture/line-failure.json" + +cat >"$fixture/branch-failure.json" <<'JSON' +{"totals":{"covered_lines":99,"num_statements":100,"covered_branches":94,"num_branches":100}} +JSON +expect_failure "branch coverage 94.00%" "$fixture/branch-failure.json" + +cat >"$fixture/zero-totals.json" <<'JSON' +{"totals":{"covered_lines":0,"num_statements":0,"covered_branches":0,"num_branches":0}} +JSON +"$verifier" "$fixture/zero-totals.json" >/dev/null + +printf '%s\n' '{}' >"$fixture/missing-totals.json" +expect_failure "coverage report has no totals object" "$fixture/missing-totals.json" + +cat >"$fixture/invalid-totals.json" <<'JSON' +{"totals":{"covered_lines":101,"num_statements":100,"covered_branches":95,"num_branches":100}} +JSON +expect_failure "coverage report has invalid totals" "$fixture/invalid-totals.json" + +printf '%s\n' '{' >"$fixture/malformed.json" +expect_failure "invalid coverage report" "$fixture/malformed.json" + +printf 'python coverage verifier tests passed\n' diff --git a/scripts/verify-python-coverage b/scripts/verify-python-coverage new file mode 100755 index 00000000..4e6931b4 --- /dev/null +++ b/scripts/verify-python-coverage @@ -0,0 +1,88 @@ +#!/bin/sh +set -eu + +if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then + echo "usage: verify-python-coverage REPORT [MINIMUM]" >&2 + exit 2 +fi + +report=$1 +minimum=${2:-95} + +if [ ! -r "$report" ]; then + echo "error: coverage report is unreadable: $report" >&2 + exit 1 +fi + +if [ -n "${PYTHON:-}" ]; then + python_command=$PYTHON +elif command -v python3 >/dev/null 2>&1; then + python_command=python3 +elif command -v python >/dev/null 2>&1; then + python_command=python +else + echo "error: Python is required to verify coverage" >&2 + exit 1 +fi + +"$python_command" - "$report" "$minimum" <<'PY' +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + + +def percentage(totals: dict[str, Any], covered_key: str, total_key: str) -> float: + covered = totals.get(covered_key) + total = totals.get(total_key) + if ( + isinstance(covered, bool) + or not isinstance(covered, int) + or isinstance(total, bool) + or not isinstance(total, int) + or covered < 0 + or total < 0 + or covered > total + ): + raise ValueError("coverage report has invalid totals") + return 100.0 if total == 0 else covered * 100.0 / total + + +def main() -> None: + report = Path(sys.argv[1]) + try: + minimum = float(sys.argv[2]) + except ValueError as error: + raise ValueError(f"invalid coverage minimum: {sys.argv[2]}") from error + if not 0.0 <= minimum <= 100.0: + raise ValueError(f"coverage minimum outside 0-100: {minimum}") + + try: + value = json.loads(report.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ValueError(f"invalid coverage report: {report}") from error + if not isinstance(value, dict) or not isinstance(value.get("totals"), dict): + raise ValueError("coverage report has no totals object") + + totals = value["totals"] + lines = percentage(totals, "covered_lines", "num_statements") + branches = percentage(totals, "covered_branches", "num_branches") + failures = [] + if lines < minimum: + failures.append(f"line coverage {lines:.2f}%") + if branches < minimum: + failures.append(f"branch coverage {branches:.2f}%") + if failures: + joined = ", ".join(failures) + raise ValueError(f"{joined} below required {minimum:.2f}%") + print(f"coverage passed: lines={lines:.2f}% branches={branches:.2f}%") + + +try: + main() +except ValueError as error: + print(f"error: {error}", file=sys.stderr) + raise SystemExit(1) from error +PY